diff --git a/.cursor/rules/never-upgrade-deps-without-explicit-confirmation.mdc b/.cursor/rules/never-upgrade-deps-without-explicit-confirmation.mdc new file mode 100644 index 0000000000..5a41e7350d --- /dev/null +++ b/.cursor/rules/never-upgrade-deps-without-explicit-confirmation.mdc @@ -0,0 +1,6 @@ +--- +description: +globs: +alwaysApply: false +--- +NEVER NEVER NEVER upgrade or changr the versions used in dependencies without explicitly asking for confirmation!! \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 2a05fef88f..ccce6d561f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7591,15 +7591,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "qrcode" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" -dependencies = [ - "image", -] - [[package]] name = "quick-error" version = "1.2.3" @@ -9823,7 +9814,6 @@ dependencies = [ "once_cell", "pem", "proptest", - "qrcode", "rand 0.8.5", "rand_chacha 0.3.1", "regex", @@ -10806,6 +10796,8 @@ dependencies = [ "futures-core", "futures-io", "futures-sink", + "futures-util", + "hashbrown 0.15.4", "pin-project-lite", "tokio", ] diff --git a/src-gui/package.json b/src-gui/package.json index 72e2424c28..fa99b61f1e 100644 --- a/src-gui/package.json +++ b/src-gui/package.json @@ -32,6 +32,7 @@ "@tauri-apps/plugin-store": "^2.0.0", "@tauri-apps/plugin-updater": "2.7.1", "@types/react-redux": "^7.1.34", + "boring-avatars": "^1.11.2", "humanize-duration": "^3.32.1", "jdenticon": "^3.3.0", "lodash": "^4.17.21", diff --git a/src-gui/src/models/tauriModelExt.ts b/src-gui/src/models/tauriModelExt.ts index 6af467e5f6..d5c5a57366 100644 --- a/src-gui/src/models/tauriModelExt.ts +++ b/src-gui/src/models/tauriModelExt.ts @@ -4,6 +4,8 @@ import { ExpiredTimelocks, GetSwapInfoResponse, PendingCompleted, + QuoteWithAddress, + SelectMakerDetails, TauriBackgroundProgress, TauriSwapProgressEvent, } from "./tauriModel"; @@ -303,3 +305,49 @@ export function isBitcoinSyncProgress( ): progress is TauriBitcoinSyncProgress { return progress.componentName === "SyncingBitcoinWallet"; } + +export type PendingSelectMakerApprovalRequest = PendingApprovalRequest & { + request: { type: "SelectMaker"; content: SelectMakerDetails }; +}; + +export interface SortableQuoteWithAddress extends QuoteWithAddress { + expiration_ts?: number; + request_id?: string; +} + +export function isPendingSelectMakerApprovalEvent( + event: ApprovalRequest, +): event is PendingSelectMakerApprovalRequest { + // Check if the request is pending + if (event.request_status.state !== "Pending") { + return false; + } + + // Check if the request is a SelectMaker request + return event.request.type === "SelectMaker"; +} + +/** + * Checks if any funds have been locked yet based on the swap progress event + * Returns true for events where funds have been locked + * @param event The TauriSwapProgressEvent to check + * @returns True if funds have been locked, false otherwise + */ +export function haveFundsBeenLocked( + event: TauriSwapProgressEvent | null, +): boolean { + if (event === null) { + return false; + } + + switch (event.type) { + case "RequestingQuote": + case "Resuming": + case "ReceivedQuote": + case "WaitingForBtcDeposit": + case "SwapSetupInflight": + return false; + } + + return true; +} diff --git a/src-gui/src/renderer/components/alert/RemainingFundsWillBeUsedAlert.tsx b/src-gui/src/renderer/components/alert/RemainingFundsWillBeUsedAlert.tsx deleted file mode 100644 index 25fd4c0d9a..0000000000 --- a/src-gui/src/renderer/components/alert/RemainingFundsWillBeUsedAlert.tsx +++ /dev/null @@ -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 ( - - } - variant="filled" - > - The remaining funds of in the wallet - will be used for the next swap - - - ); -} diff --git a/src-gui/src/renderer/components/alert/SwapStatusAlert/SwapStatusAlert.tsx b/src-gui/src/renderer/components/alert/SwapStatusAlert/SwapStatusAlert.tsx index 17cfdee49f..cec3719903 100644 --- a/src-gui/src/renderer/components/alert/SwapStatusAlert/SwapStatusAlert.tsx +++ b/src-gui/src/renderer/components/alert/SwapStatusAlert/SwapStatusAlert.tsx @@ -14,6 +14,7 @@ import HumanizedBitcoinBlockDuration from "../../other/HumanizedBitcoinBlockDura import TruncatedText from "../../other/TruncatedText"; import { SwapMoneroRecoveryButton } from "../../pages/history/table/SwapMoneroRecoveryButton"; import { TimelockTimeline } from "./TimelockTimeline"; +import { useIsSpecificSwapRunning } from "store/hooks"; /** * Component for displaying a list of messages. @@ -233,13 +234,15 @@ const UNUSUAL_AMOUNT_OF_TIME_HAS_PASSED_THRESHOLD = 72 - 4; */ export default function SwapStatusAlert({ swap, - isRunning, onlyShowIfUnusualAmountOfTimeHasPassed, }: { swap: GetSwapInfoResponseExt; - isRunning: boolean; onlyShowIfUnusualAmountOfTimeHasPassed?: boolean; }) { + if (swap == null) { + return null; + } + // If the swap is completed, we do not need to display anything if (!isGetSwapInfoResponseRunningSwap(swap)) { return null; @@ -250,16 +253,18 @@ export default function SwapStatusAlert({ return null; } - // If we are only showing if an unusual amount of time has passed, we need to check if the swap has been running for a while - if ( - onlyShowIfUnusualAmountOfTimeHasPassed && + const hasUnusualAmountOfTimePassed = swap.timelock.type === "None" && swap.timelock.content.blocks_left > - UNUSUAL_AMOUNT_OF_TIME_HAS_PASSED_THRESHOLD - ) { + UNUSUAL_AMOUNT_OF_TIME_HAS_PASSED_THRESHOLD; + + // If we are only showing if an unusual amount of time has passed, we need to check if the swap has been running for a while + if (onlyShowIfUnusualAmountOfTimeHasPassed && hasUnusualAmountOfTimePassed) { return null; } + const isRunning = useIsSpecificSwapRunning(swap.swap_id); + return ( {isRunning ? ( - "Swap has been running for a while" + hasUnusualAmountOfTimePassed ? ( + "Swap has been running for a while" + ) : ( + "Swap is running" + ) ) : ( <> Swap {swap.swap_id} is not running diff --git a/src-gui/src/renderer/components/alert/SwapTxLockAlertsBox.tsx b/src-gui/src/renderer/components/alert/SwapTxLockAlertsBox.tsx index 4c2791813b..dcdf065f7d 100644 --- a/src-gui/src/renderer/components/alert/SwapTxLockAlertsBox.tsx +++ b/src-gui/src/renderer/components/alert/SwapTxLockAlertsBox.tsx @@ -11,7 +11,7 @@ export default function SwapTxLockAlertsBox() { return ( {swaps.map((swap) => ( - + ))} ); diff --git a/src-gui/src/renderer/components/inputs/MoneroAddressTextField.tsx b/src-gui/src/renderer/components/inputs/MoneroAddressTextField.tsx index 6db1e72294..e0b570c2bb 100644 --- a/src-gui/src/renderer/components/inputs/MoneroAddressTextField.tsx +++ b/src-gui/src/renderer/components/inputs/MoneroAddressTextField.tsx @@ -53,6 +53,9 @@ export default function MoneroAddressTextField({ setAddresses(response.addresses); }; fetchAddresses(); + + const interval = setInterval(fetchAddresses, 5000); + return () => clearInterval(interval); }, []); // Event handlers diff --git a/src-gui/src/renderer/components/modal/SwapSuspendAlert.tsx b/src-gui/src/renderer/components/modal/SwapSuspendAlert.tsx index d375f35bf7..51b3717965 100644 --- a/src-gui/src/renderer/components/modal/SwapSuspendAlert.tsx +++ b/src-gui/src/renderer/components/modal/SwapSuspendAlert.tsx @@ -5,7 +5,13 @@ import { DialogContent, DialogContentText, DialogTitle, + List, + ListItem, + ListItemIcon, + ListItemText, + Typography, } from "@mui/material"; +import CircleIcon from "@mui/icons-material/Circle"; import { suspendCurrentSwap } from "renderer/rpc"; import PromiseInvokeButton from "../PromiseInvokeButton"; @@ -20,10 +26,42 @@ export default function SwapSuspendAlert({ }: SwapCancelAlertProps) { return ( - Force stop running operation? + Suspend running swap? - - Are you sure you want to force stop the running swap? + + + + + + + + + + + + + + Refund timelocks will not be paused. They + will continue to count down until they expire + + } + /> + + + + + + + + + + + + + + @@ -35,7 +73,7 @@ export default function SwapSuspendAlert({ onSuccess={onClose} onInvoke={suspendCurrentSwap} > - Force stop + Suspend diff --git a/src-gui/src/renderer/components/modal/swap/BitcoinQrCode.tsx b/src-gui/src/renderer/components/modal/swap/BitcoinQrCode.tsx deleted file mode 100644 index e9fa7bf651..0000000000 --- a/src-gui/src/renderer/components/modal/swap/BitcoinQrCode.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Box } from "@mui/material"; -import QRCode from "react-qr-code"; - -export default function BitcoinQrCode({ address }: { address: string }) { - return ( - - - - ); -} diff --git a/src-gui/src/renderer/components/modal/swap/SwapDialog.tsx b/src-gui/src/renderer/components/modal/swap/SwapDialog.tsx index 8950e608a6..64e74ef830 100644 --- a/src-gui/src/renderer/components/modal/swap/SwapDialog.tsx +++ b/src-gui/src/renderer/components/modal/swap/SwapDialog.tsx @@ -1,18 +1,11 @@ -import { - Box, - Button, - Dialog, - DialogActions, - DialogContent, -} from "@mui/material"; +import { Box, Dialog, DialogActions, DialogContent } from "@mui/material"; import { useState } from "react"; -import { swapReset } from "store/features/swapSlice"; -import { useAppDispatch, useAppSelector, useIsSwapRunning } from "store/hooks"; -import SwapSuspendAlert from "../SwapSuspendAlert"; +import { useAppSelector } from "store/hooks"; import DebugPage from "./pages/DebugPage"; -import SwapStatePage from "./pages/SwapStatePage"; +import SwapStatePage from "renderer/components/pages/swap/swap/SwapStatePage"; import SwapDialogTitle from "./SwapDialogTitle"; import SwapStateStepper from "./SwapStateStepper"; +import CancelButton from "renderer/components/pages/swap/swap/CancelButton"; export default function SwapDialog({ open, @@ -22,26 +15,13 @@ export default function SwapDialog({ onClose: () => void; }) { const swap = useAppSelector((state) => state.swap); - const isSwapRunning = useIsSwapRunning(); const [debug, setDebug] = useState(false); - const [openSuspendAlert, setOpenSuspendAlert] = useState(false); - - const dispatch = useAppDispatch(); - - function onCancel() { - if (isSwapRunning) { - setOpenSuspendAlert(true); - } else { - onClose(); - dispatch(swapReset()); - } - } // This prevents an issue where the Dialog is shown for a split second without a present swap state if (!open) return null; return ( - + - - + - - setOpenSuspendAlert(false)} - /> ); } diff --git a/src-gui/src/renderer/components/modal/swap/SwapDialogTitle.tsx b/src-gui/src/renderer/components/modal/swap/SwapDialogTitle.tsx index 954e859137..5d3a1054d8 100644 --- a/src-gui/src/renderer/components/modal/swap/SwapDialogTitle.tsx +++ b/src-gui/src/renderer/components/modal/swap/SwapDialogTitle.tsx @@ -1,7 +1,6 @@ import { Box, DialogTitle, Typography } from "@mui/material"; import DebugPageSwitchBadge from "./pages/DebugPageSwitchBadge"; import FeedbackSubmitBadge from "./pages/FeedbackSubmitBadge"; -import TorStatusBadge from "./pages/TorStatusBadge"; export default function SwapDialogTitle({ title, @@ -24,7 +23,6 @@ export default function SwapDialogTitle({ - ); diff --git a/src-gui/src/renderer/components/modal/swap/SwapStateStepper.tsx b/src-gui/src/renderer/components/modal/swap/SwapStateStepper.tsx index 4fd3d29725..35b374ab8b 100644 --- a/src-gui/src/renderer/components/modal/swap/SwapStateStepper.tsx +++ b/src-gui/src/renderer/components/modal/swap/SwapStateStepper.tsx @@ -57,7 +57,7 @@ function getActiveStep(state: SwapState | null): PathStep | null { case "ReceivedQuote": case "WaitingForBtcDeposit": case "SwapSetupInflight": - return [PathType.HAPPY_PATH, 0, isReleased]; + return null; // No funds have been locked yet // Step 1: Waiting for Bitcoin lock confirmation // Bitcoin has been locked, waiting for the counterparty to lock their XMR diff --git a/src-gui/src/renderer/components/modal/swap/pages/DebugPage.tsx b/src-gui/src/renderer/components/modal/swap/pages/DebugPage.tsx index 760469d88b..56e536dd21 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/DebugPage.tsx +++ b/src-gui/src/renderer/components/modal/swap/pages/DebugPage.tsx @@ -8,13 +8,11 @@ import JsonTreeView from "../../../other/JSONViewTree"; import CliLogsBox from "../../../other/RenderedCliLog"; export default function DebugPage() { - const torStdOut = useAppSelector((s) => s.tor.stdOut); const logs = useActiveSwapLogs(); - const guiState = useAppSelector((s) => s); const cliState = useActiveSwapInfo(); return ( - + - - - diff --git a/src-gui/src/renderer/components/modal/swap/pages/TorStatusBadge.tsx b/src-gui/src/renderer/components/modal/swap/pages/TorStatusBadge.tsx deleted file mode 100644 index 47543d427b..0000000000 --- a/src-gui/src/renderer/components/modal/swap/pages/TorStatusBadge.tsx +++ /dev/null @@ -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 ( - - - - - - ); - } - - return <>; -} diff --git a/src-gui/src/renderer/components/modal/swap/pages/in_progress/EncryptedSignatureSentPage.tsx b/src-gui/src/renderer/components/modal/swap/pages/in_progress/EncryptedSignatureSentPage.tsx deleted file mode 100644 index f9807fc141..0000000000 --- a/src-gui/src/renderer/components/modal/swap/pages/in_progress/EncryptedSignatureSentPage.tsx +++ /dev/null @@ -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 ( - - - - - - - ); -} diff --git a/src-gui/src/renderer/components/modal/swap/pages/in_progress/SyncingMoneroWalletPage.tsx b/src-gui/src/renderer/components/modal/swap/pages/in_progress/SyncingMoneroWalletPage.tsx deleted file mode 100644 index 588c810e48..0000000000 --- a/src-gui/src/renderer/components/modal/swap/pages/in_progress/SyncingMoneroWalletPage.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle"; - -export function SyncingMoneroWalletPage() { - return ( - - ); -} diff --git a/src-gui/src/renderer/components/modal/swap/pages/init/DepositAmountHelper.tsx b/src-gui/src/renderer/components/modal/swap/pages/init/DepositAmountHelper.tsx deleted file mode 100644 index 59202d949a..0000000000 --- a/src-gui/src/renderer/components/modal/swap/pages/init/DepositAmountHelper.tsx +++ /dev/null @@ -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 ( - - - Depositing {bitcoinBalance > 0 && <>another} - - 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", - }, - }} - /> - - BTC will give you approximately{" "} - . - - - ); -} diff --git a/src-gui/src/renderer/components/modal/swap/pages/init/WaitingForBitcoinDepositPage.tsx b/src-gui/src/renderer/components/modal/swap/pages/init/WaitingForBitcoinDepositPage.tsx deleted file mode 100644 index 0e8f3fc24c..0000000000 --- a/src-gui/src/renderer/components/modal/swap/pages/init/WaitingForBitcoinDepositPage.tsx +++ /dev/null @@ -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 ( - - - -
    - {max_giveable > 0 ? ( -
  • - You have already deposited enough funds to swap{" "} - . However, that is below - the minimum amount required to start the swap. -
  • - ) : null} -
  • - Send any amount between{" "} - and{" "} - {" "} - to the address above - {max_giveable > 0 && ( - <> (on top of the already deposited funds) - )} -
  • -
  • - Bitcoin sent to this this address will be converted into - Monero at an exchange rate of{" ≈ "} - -
  • -
  • - The Network fee of{" ≈ "} - will - automatically be deducted from the deposited coins -
  • -
  • - After the deposit is detected, you'll get to confirm the exact - details before your funds are locked -
  • -
  • - -
  • -
-
- - - 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. - -
- } - icon={} - /> -
- ); -} diff --git a/src-gui/src/renderer/components/modal/wallet/pages/BitcoinWithdrawTxInMempoolPage.tsx b/src-gui/src/renderer/components/modal/wallet/pages/BitcoinWithdrawTxInMempoolPage.tsx index db080a56c9..268f8a90df 100644 --- a/src-gui/src/renderer/components/modal/wallet/pages/BitcoinWithdrawTxInMempoolPage.tsx +++ b/src-gui/src/renderer/components/modal/wallet/pages/BitcoinWithdrawTxInMempoolPage.tsx @@ -1,5 +1,5 @@ import { DialogContentText } from "@mui/material"; -import BitcoinTransactionInfoBox from "../../swap/BitcoinTransactionInfoBox"; +import BitcoinTransactionInfoBox from "renderer/components/pages/swap/swap/components/BitcoinTransactionInfoBox"; export default function BtcTxInMempoolPageContent({ withdrawTxId, diff --git a/src-gui/src/renderer/components/other/ActionableMonospaceTextBox.tsx b/src-gui/src/renderer/components/other/ActionableMonospaceTextBox.tsx index c960f67bb8..88efa375d7 100644 --- a/src-gui/src/renderer/components/other/ActionableMonospaceTextBox.tsx +++ b/src-gui/src/renderer/components/other/ActionableMonospaceTextBox.tsx @@ -1,8 +1,5 @@ import { Box, Button, IconButton, Tooltip } from "@mui/material"; -import { - FileCopyOutlined, - CropFree as CropFreeIcon, -} from "@mui/icons-material"; +import { FileCopyOutlined, QrCode as QrCodeIcon } from "@mui/icons-material"; import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { useState } from "react"; import MonospaceTextBox from "./MonospaceTextBox"; @@ -111,7 +108,7 @@ export default function ActionableMonospaceTextBox({ size="small" sx={{ marginLeft: 1 }} > - + )} diff --git a/src-gui/src/renderer/components/pages/help/ContactInfoBox.tsx b/src-gui/src/renderer/components/pages/help/ContactInfoBox.tsx index 5b5e569b0d..6aa56b1c67 100644 --- a/src-gui/src/renderer/components/pages/help/ContactInfoBox.tsx +++ b/src-gui/src/renderer/components/pages/help/ContactInfoBox.tsx @@ -1,6 +1,6 @@ import { Box, Button, Typography } from "@mui/material"; import { open } from "@tauri-apps/plugin-shell"; -import InfoBox from "../../modal/swap/InfoBox"; +import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox"; const GITHUB_ISSUE_URL = "https://github.com/UnstoppableSwap/core/issues/new/choose"; diff --git a/src-gui/src/renderer/components/pages/help/ConversationsBox.tsx b/src-gui/src/renderer/components/pages/help/ConversationsBox.tsx index b34256701b..c4f4803efd 100644 --- a/src-gui/src/renderer/components/pages/help/ConversationsBox.tsx +++ b/src-gui/src/renderer/components/pages/help/ConversationsBox.tsx @@ -27,7 +27,7 @@ import { } from "@mui/material"; import ChatIcon from "@mui/icons-material/Chat"; import SendIcon from "@mui/icons-material/Send"; -import InfoBox from "renderer/components/modal/swap/InfoBox"; +import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox"; import TruncatedText from "renderer/components/other/TruncatedText"; import clsx from "clsx"; import { diff --git a/src-gui/src/renderer/components/pages/help/DaemonControlBox.tsx b/src-gui/src/renderer/components/pages/help/DaemonControlBox.tsx index 5290b1744e..bec674dc44 100644 --- a/src-gui/src/renderer/components/pages/help/DaemonControlBox.tsx +++ b/src-gui/src/renderer/components/pages/help/DaemonControlBox.tsx @@ -3,8 +3,8 @@ import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import { useAppSelector } from "store/hooks"; -import InfoBox from "../../modal/swap/InfoBox"; -import CliLogsBox from "../../other/RenderedCliLog"; +import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox"; +import CliLogsBox from "renderer/components/other/RenderedCliLog"; import { getDataDir, initializeContext } from "renderer/rpc"; import { relaunch } from "@tauri-apps/plugin-process"; import RotateLeftIcon from "@mui/icons-material/RotateLeft"; diff --git a/src-gui/src/renderer/components/pages/help/DiscoveryBox.tsx b/src-gui/src/renderer/components/pages/help/DiscoveryBox.tsx index c2d41c9ddb..0efce39130 100644 --- a/src-gui/src/renderer/components/pages/help/DiscoveryBox.tsx +++ b/src-gui/src/renderer/components/pages/help/DiscoveryBox.tsx @@ -1,5 +1,5 @@ import { Box, Typography, styled } from "@mui/material"; -import InfoBox from "renderer/components/modal/swap/InfoBox"; +import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox"; import { useSettings } from "store/hooks"; import { Search } from "@mui/icons-material"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; diff --git a/src-gui/src/renderer/components/pages/help/DonateInfoBox.tsx b/src-gui/src/renderer/components/pages/help/DonateInfoBox.tsx index 087b0b89a1..485240d1d4 100644 --- a/src-gui/src/renderer/components/pages/help/DonateInfoBox.tsx +++ b/src-gui/src/renderer/components/pages/help/DonateInfoBox.tsx @@ -1,6 +1,6 @@ import { Link, Typography } from "@mui/material"; -import MoneroIcon from "../../icons/MoneroIcon"; -import DepositAddressInfoBox from "../../modal/swap/DepositAddressInfoBox"; +import MoneroIcon from "renderer/components/icons/MoneroIcon"; +import DepositAddressInfoBox from "renderer/components/pages/swap/swap/components/DepositAddressInfoBox"; const XMR_DONATE_ADDRESS = "87jS4C7ngk9EHdqFFuxGFgg8AyH63dRUoULshWDybFJaP75UA89qsutG5B1L1QTc4w228nsqsv8EjhL7bz8fB3611Mh98mg"; diff --git a/src-gui/src/renderer/components/pages/help/ExportDataBox.tsx b/src-gui/src/renderer/components/pages/help/ExportDataBox.tsx index 3b3ce4ae2c..26e01fd5ef 100644 --- a/src-gui/src/renderer/components/pages/help/ExportDataBox.tsx +++ b/src-gui/src/renderer/components/pages/help/ExportDataBox.tsx @@ -9,7 +9,7 @@ import { Link, DialogContentText, } from "@mui/material"; -import InfoBox from "renderer/components/modal/swap/InfoBox"; +import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox"; import { useState } from "react"; import { getWalletDescriptor } from "renderer/rpc"; import { ExportBitcoinWalletResponse } from "models/tauriModel"; diff --git a/src-gui/src/renderer/components/pages/help/FeedbackInfoBox.tsx b/src-gui/src/renderer/components/pages/help/FeedbackInfoBox.tsx index 7359ade35c..9892541226 100644 --- a/src-gui/src/renderer/components/pages/help/FeedbackInfoBox.tsx +++ b/src-gui/src/renderer/components/pages/help/FeedbackInfoBox.tsx @@ -1,7 +1,7 @@ import { Button, Typography } from "@mui/material"; import { useState } from "react"; -import FeedbackDialog from "../../modal/feedback/FeedbackDialog"; -import InfoBox from "../../modal/swap/InfoBox"; +import FeedbackDialog from "renderer/components/modal/feedback/FeedbackDialog"; +import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox"; export default function FeedbackInfoBox() { const [showDialog, setShowDialog] = useState(false); diff --git a/src-gui/src/renderer/components/pages/help/MoneroPoolHealthBox.tsx b/src-gui/src/renderer/components/pages/help/MoneroPoolHealthBox.tsx index b2f692b4dd..8e86597819 100644 --- a/src-gui/src/renderer/components/pages/help/MoneroPoolHealthBox.tsx +++ b/src-gui/src/renderer/components/pages/help/MoneroPoolHealthBox.tsx @@ -11,7 +11,7 @@ import { LinearProgress, useTheme, } from "@mui/material"; -import InfoBox from "renderer/components/modal/swap/InfoBox"; +import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox"; import { ReliableNodeInfo } from "models/tauriModel"; import NetworkWifiIcon from "@mui/icons-material/NetworkWifi"; import { useAppSelector } from "store/hooks"; diff --git a/src-gui/src/renderer/components/pages/help/SettingsBox.tsx b/src-gui/src/renderer/components/pages/help/SettingsBox.tsx index d236620951..0575c12228 100644 --- a/src-gui/src/renderer/components/pages/help/SettingsBox.tsx +++ b/src-gui/src/renderer/components/pages/help/SettingsBox.tsx @@ -57,7 +57,7 @@ import { import { getNetwork } from "store/config"; import { currencySymbol } from "utils/formatUtils"; -import InfoBox from "renderer/components/modal/swap/InfoBox"; +import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox"; import { isValidMultiAddressWithPeerId } from "utils/parseUtils"; import { getNodeStatus } from "renderer/rpc"; import { setStatus } from "store/features/nodesSlice"; diff --git a/src-gui/src/renderer/components/pages/history/HistoryPage.tsx b/src-gui/src/renderer/components/pages/history/HistoryPage.tsx index 8ff9b7335c..580dd9aeed 100644 --- a/src-gui/src/renderer/components/pages/history/HistoryPage.tsx +++ b/src-gui/src/renderer/components/pages/history/HistoryPage.tsx @@ -1,18 +1,13 @@ import { Typography } from "@mui/material"; -import { useAppSelector } from "store/hooks"; import SwapTxLockAlertsBox from "../../alert/SwapTxLockAlertsBox"; -import SwapDialog from "../../modal/swap/SwapDialog"; import HistoryTable from "./table/HistoryTable"; export default function HistoryPage() { - const showDialog = useAppSelector((state) => state.swap.state !== null); - return ( <> History - {}} /> ); } diff --git a/src-gui/src/renderer/components/pages/history/table/HistoryRowActions.tsx b/src-gui/src/renderer/components/pages/history/table/HistoryRowActions.tsx index d6aa504b2c..f14b2a596c 100644 --- a/src-gui/src/renderer/components/pages/history/table/HistoryRowActions.tsx +++ b/src-gui/src/renderer/components/pages/history/table/HistoryRowActions.tsx @@ -12,49 +12,65 @@ import { isBobStateNamePossiblyRefundableSwap, } from "models/tauriModelExt"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; -import { resumeSwap } from "renderer/rpc"; +import { resumeSwap, suspendCurrentSwap } from "renderer/rpc"; +import { + useIsSpecificSwapRunning, + useIsSwapRunning, + useIsSwapRunningAndHasFundsLocked, +} from "store/hooks"; +import { useNavigate } from "react-router-dom"; export function SwapResumeButton({ swap, children, ...props }: ButtonProps & { swap: GetSwapInfoResponse }) { - return ( - } - onInvoke={() => resumeSwap(swap.swap_id)} - {...props} - > - {children} - - ); -} + const navigate = useNavigate(); -export function SwapCancelRefundButton({ - swap, - ...props -}: { swap: GetSwapInfoResponseExt } & ButtonProps) { - const cancelOrRefundable = - isBobStateNamePossiblyCancellableSwap(swap.state_name) || - isBobStateNamePossiblyRefundableSwap(swap.state_name); + // 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); - if (!cancelOrRefundable) { - return <>; + // 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 ( } + onInvoke={resume} {...props} - onInvoke={async () => { - // TODO: Implement this using the Tauri RPC - throw new Error("Not implemented"); - }} > - Attempt manual Cancel & Refund + {children} ); } diff --git a/src-gui/src/renderer/components/pages/swap/SwapPage.tsx b/src-gui/src/renderer/components/pages/swap/SwapPage.tsx index c36cf77ae3..f464a3bded 100644 --- a/src-gui/src/renderer/components/pages/swap/SwapPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/SwapPage.tsx @@ -1,6 +1,6 @@ import { Box } from "@mui/material"; import ApiAlertsBox from "./ApiAlertsBox"; -import SwapWidget from "./SwapWidget"; +import SwapWidget from "./swap/SwapWidget"; export default function SwapPage() { return ( diff --git a/src-gui/src/renderer/components/pages/swap/SwapWidget.tsx b/src-gui/src/renderer/components/pages/swap/SwapWidget.tsx deleted file mode 100644 index 38ad5a4679..0000000000 --- a/src-gui/src/renderer/components/pages/swap/SwapWidget.tsx +++ /dev/null @@ -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 ( - - - Swap - - - ); -} - -function HasMakerSwapWidget({ - selectedMaker, -}: { - selectedMaker: ExtendedMakerStatus; -}) { - const forceShowDialog = useAppSelector((state) => state.swap.state !== null); - const [showDialog, setShowDialog] = useState(false); - const [btcFieldValue, setBtcFieldValue] = useState( - satsToBtc(selectedMaker.minSwapAmount), - ); - const [xmrFieldValue, setXmrFieldValue] = useState(1); - - function onBtcAmountChange(event: ChangeEvent) { - 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 - ({ - width: "min(480px, 100%)", - minHeight: "150px", - display: "grid", - padding: theme.spacing(2), - gridGap: theme.spacing(1), - })} - > - - <TextField - label="For this many BTC" - size="medium" - variant="outlined" - value={btcFieldValue} - onChange={onBtcAmountChange} - error={!!getBtcFieldError()} - helperText={getBtcFieldError()} - autoFocus - InputProps={{ - endAdornment: <InputAdornment position="end">BTC</InputAdornment>, - }} - /> - <Box sx={{ display: "flex", justifyContent: "center" }}> - <ArrowDownwardIcon fontSize="small" /> - </Box> - <TextField - label="You'd receive that many XMR" - variant="outlined" - size="medium" - value={xmrFieldValue.toFixed(6)} - InputProps={{ - endAdornment: <InputAdornment position="end">XMR</InputAdornment>, - }} - /> - <MakerSelect /> - <Fab variant="extended" color="primary" onClick={handleGuideDialogOpen}> - <SwapHorizIcon sx={{ marginRight: 1 }} /> - Swap - </Fab> - <SwapDialog - open={showDialog || forceShowDialog} - onClose={() => setShowDialog(false)} - /> - </Paper> - ); -} - -function HasNoMakersSwapWidget() { - const forceShowDialog = useAppSelector((state) => state.swap.state !== null); - const isPublicRegistryDown = useAppSelector((state) => - isRegistryDown(state.makers.registry.connectionFailsCount), - ); - - const alertBox = isPublicRegistryDown ? ( - <Alert severity="info"> - <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> - <Typography> - Currently, the public registry of makers seems to be unreachable. - Here's what you can do: - <ul> - <li>Try discovering a maker by connecting to a rendezvous point</li> - <li> - Try again later when the public registry may be reachable again - </li> - </ul> - </Typography> - <Box> - <MakerSubmitDialogOpenButton /> - </Box> - </Box> - </Alert> - ) : ( - <Alert severity="info"> - <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> - <Typography> - Currently, there are no makers (trading partners) available in the - official registry. Here's what you can do: - <ul> - <li>Try discovering a maker by connecting to a rendezvous point</li> - <li>Add a new maker to the public registry</li> - <li>Try again later when more makers may be available</li> - </ul> - </Typography> - <Box sx={{ display: "flex", gap: 1 }}> - <MakerSubmitDialogOpenButton /> - </Box> - </Box> - </Alert> - ); - - return ( - <Box> - {alertBox} - <SwapDialog open={forceShowDialog} onClose={() => {}} /> - </Box> - ); -} - -function MakerLoadingSwapWidget() { - return ( - // 'elevation' prop can't be passed down (type def issue) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - <Box - component={Paper} - elevation={15} - sx={{ - width: "min(480px, 100%)", - minHeight: "150px", - display: "grid", - padding: 1, - gridGap: 1, - }} - > - <Title /> - <LinearProgress /> - </Box> - ); -} - -export default function SwapWidget() { - const selectedMaker = useAppSelector((state) => state.makers.selectedMaker); - // If we fail more than RECONNECTION_ATTEMPTS_UNTIL_ASSUME_DOWN reconnect attempts, we'll show the "no makers" widget. We can assume the public registry is down. - const makerLoading = useAppSelector( - (state) => - state.makers.registry.makers === null && - !isRegistryDown(state.makers.registry.connectionFailsCount), - ); - - if (makerLoading) { - return <MakerLoadingSwapWidget />; - } - - if (selectedMaker === null) { - return <HasNoMakersSwapWidget />; - } - - return <HasMakerSwapWidget selectedMaker={selectedMaker} />; -} diff --git a/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx b/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx new file mode 100644 index 0000000000..6650706e0e --- /dev/null +++ b/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx @@ -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> + </> + ); +} diff --git a/src-gui/src/renderer/components/modal/swap/pages/SwapStatePage.tsx b/src-gui/src/renderer/components/pages/swap/swap/SwapStatePage.tsx similarity index 94% rename from src-gui/src/renderer/components/modal/swap/pages/SwapStatePage.tsx rename to src-gui/src/renderer/components/pages/swap/swap/SwapStatePage.tsx index 62ea974f45..52f5e53d20 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/SwapStatePage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/SwapStatePage.tsx @@ -1,6 +1,6 @@ import { SwapState } from "models/storeModel"; import { TauriSwapProgressEventType } from "models/tauriModelExt"; -import CircularProgressWithSubtitle from "../CircularProgressWithSubtitle"; +import CircularProgressWithSubtitle from "./components/CircularProgressWithSubtitle"; import BitcoinPunishedPage from "./done/BitcoinPunishedPage"; import { BitcoinRefundedPage, @@ -20,9 +20,10 @@ import SwapSetupInflightPage from "./in_progress/SwapSetupInflightPage"; import WaitingForXmrConfirmationsBeforeRedeemPage from "./in_progress/WaitingForXmrConfirmationsBeforeRedeemPage"; import XmrLockedPage from "./in_progress/XmrLockedPage"; import XmrLockTxInMempoolPage from "./in_progress/XmrLockInMempoolPage"; -import InitPage from "./init/InitPage"; -import WaitingForBitcoinDepositPage from "./init/WaitingForBitcoinDepositPage"; import { exhaustiveGuard } from "utils/typescriptUtils"; +import DepositAndChooseOfferPage from "renderer/components/pages/swap/swap/init/deposit_and_choose_offer/DepositAndChooseOfferPage"; +import InitPage from "./init/InitPage"; +import { Box } from "@mui/material"; export default function SwapStatePage({ state }: { state: SwapState | null }) { if (state === null) { @@ -41,7 +42,7 @@ export default function SwapStatePage({ state }: { state: SwapState | null }) { case "WaitingForBtcDeposit": // This double check is necessary for the typescript compiler to infer types if (state.curr.type === "WaitingForBtcDeposit") { - return <WaitingForBitcoinDepositPage {...state.curr.content} />; + return <DepositAndChooseOfferPage {...state.curr.content} />; } break; case "SwapSetupInflight": diff --git a/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx b/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx new file mode 100644 index 0000000000..9fd8b73d6f --- /dev/null +++ b/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx @@ -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> + ); +} diff --git a/src-gui/src/renderer/components/pages/swap/swap/components/BitcoinQrCode.tsx b/src-gui/src/renderer/components/pages/swap/swap/components/BitcoinQrCode.tsx new file mode 100644 index 0000000000..c1a009e906 --- /dev/null +++ b/src-gui/src/renderer/components/pages/swap/swap/components/BitcoinQrCode.tsx @@ -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> + ); +} diff --git a/src-gui/src/renderer/components/modal/swap/BitcoinTransactionInfoBox.tsx b/src-gui/src/renderer/components/pages/swap/swap/components/BitcoinTransactionInfoBox.tsx similarity index 100% rename from src-gui/src/renderer/components/modal/swap/BitcoinTransactionInfoBox.tsx rename to src-gui/src/renderer/components/pages/swap/swap/components/BitcoinTransactionInfoBox.tsx diff --git a/src-gui/src/renderer/components/modal/swap/CircularProgressWithSubtitle.tsx b/src-gui/src/renderer/components/pages/swap/swap/components/CircularProgressWithSubtitle.tsx similarity index 100% rename from src-gui/src/renderer/components/modal/swap/CircularProgressWithSubtitle.tsx rename to src-gui/src/renderer/components/pages/swap/swap/components/CircularProgressWithSubtitle.tsx diff --git a/src-gui/src/renderer/components/modal/swap/ClipbiardIconButton.tsx b/src-gui/src/renderer/components/pages/swap/swap/components/ClipbiardIconButton.tsx similarity index 100% rename from src-gui/src/renderer/components/modal/swap/ClipbiardIconButton.tsx rename to src-gui/src/renderer/components/pages/swap/swap/components/ClipbiardIconButton.tsx diff --git a/src-gui/src/renderer/components/modal/swap/DepositAddressInfoBox.tsx b/src-gui/src/renderer/components/pages/swap/swap/components/DepositAddressInfoBox.tsx similarity index 100% rename from src-gui/src/renderer/components/modal/swap/DepositAddressInfoBox.tsx rename to src-gui/src/renderer/components/pages/swap/swap/components/DepositAddressInfoBox.tsx diff --git a/src-gui/src/renderer/components/modal/swap/InfoBox.tsx b/src-gui/src/renderer/components/pages/swap/swap/components/InfoBox.tsx similarity index 100% rename from src-gui/src/renderer/components/modal/swap/InfoBox.tsx rename to src-gui/src/renderer/components/pages/swap/swap/components/InfoBox.tsx diff --git a/src-gui/src/renderer/components/modal/swap/MoneroTransactionInfoBox.tsx b/src-gui/src/renderer/components/pages/swap/swap/components/MoneroTransactionInfoBox.tsx similarity index 100% rename from src-gui/src/renderer/components/modal/swap/MoneroTransactionInfoBox.tsx rename to src-gui/src/renderer/components/pages/swap/swap/components/MoneroTransactionInfoBox.tsx diff --git a/src-gui/src/renderer/components/modal/swap/TransactionInfoBox.tsx b/src-gui/src/renderer/components/pages/swap/swap/components/TransactionInfoBox.tsx similarity index 100% rename from src-gui/src/renderer/components/modal/swap/TransactionInfoBox.tsx rename to src-gui/src/renderer/components/pages/swap/swap/components/TransactionInfoBox.tsx diff --git a/src-gui/src/renderer/components/modal/swap/pages/done/BitcoinPunishedPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/done/BitcoinPunishedPage.tsx similarity index 91% rename from src-gui/src/renderer/components/modal/swap/pages/done/BitcoinPunishedPage.tsx rename to src-gui/src/renderer/components/pages/swap/swap/done/BitcoinPunishedPage.tsx index d3b14a63fa..bf18a6fd9a 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/done/BitcoinPunishedPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/done/BitcoinPunishedPage.tsx @@ -1,5 +1,5 @@ import { Box, DialogContentText } from "@mui/material"; -import FeedbackInfoBox from "../../../../pages/help/FeedbackInfoBox"; +import FeedbackInfoBox from "renderer/components/pages/help/FeedbackInfoBox"; import { TauriSwapProgressEventExt } from "models/tauriModelExt"; export default function BitcoinPunishedPage({ @@ -10,7 +10,7 @@ export default function BitcoinPunishedPage({ | TauriSwapProgressEventExt<"CooperativeRedeemRejected">; }) { return ( - <Box> + <> <DialogContentText> Unfortunately, the swap was unsuccessful. Since you did not refund in time, the Bitcoin has been lost. However, with the cooperation of the @@ -26,6 +26,6 @@ export default function BitcoinPunishedPage({ )} </DialogContentText> <FeedbackInfoBox /> - </Box> + </> ); } diff --git a/src-gui/src/renderer/components/modal/swap/pages/done/BitcoinRefundedPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/done/BitcoinRefundedPage.tsx similarity index 91% rename from src-gui/src/renderer/components/modal/swap/pages/done/BitcoinRefundedPage.tsx rename to src-gui/src/renderer/components/pages/swap/swap/done/BitcoinRefundedPage.tsx index 2c2812ec68..0c82e52292 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/done/BitcoinRefundedPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/done/BitcoinRefundedPage.tsx @@ -1,8 +1,8 @@ import { Box, DialogContentText } from "@mui/material"; import { TauriSwapProgressEventContent } from "models/tauriModelExt"; import { useActiveSwapInfo } from "store/hooks"; -import FeedbackInfoBox from "../../../../pages/help/FeedbackInfoBox"; -import BitcoinTransactionInfoBox from "../../BitcoinTransactionInfoBox"; +import FeedbackInfoBox from "renderer/components/pages/help/FeedbackInfoBox"; +import BitcoinTransactionInfoBox from "renderer/components/pages/swap/swap/components/BitcoinTransactionInfoBox"; export function BitcoinRefundPublishedPage({ btc_refund_txid, @@ -66,7 +66,7 @@ function MultiBitcoinRefundedPage({ ) : null; return ( - <Box> + <> <DialogContentText> Unfortunately, the swap was not successful. However, rest assured that all your Bitcoin has been refunded to the specified address. The swap @@ -87,6 +87,6 @@ function MultiBitcoinRefundedPage({ /> <FeedbackInfoBox /> </Box> - </Box> + </> ); } diff --git a/src-gui/src/renderer/components/modal/swap/pages/done/XmrRedeemInMempoolPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/done/XmrRedeemInMempoolPage.tsx similarity index 92% rename from src-gui/src/renderer/components/modal/swap/pages/done/XmrRedeemInMempoolPage.tsx rename to src-gui/src/renderer/components/pages/swap/swap/done/XmrRedeemInMempoolPage.tsx index a126fa36b8..f7f0f59384 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/done/XmrRedeemInMempoolPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/done/XmrRedeemInMempoolPage.tsx @@ -1,7 +1,7 @@ import { Box, DialogContentText, Typography } from "@mui/material"; import { TauriSwapProgressEventContent } from "models/tauriModelExt"; -import FeedbackInfoBox from "../../../../pages/help/FeedbackInfoBox"; -import MoneroTransactionInfoBox from "../../MoneroTransactionInfoBox"; +import FeedbackInfoBox from "renderer/components/pages/help/FeedbackInfoBox"; +import MoneroTransactionInfoBox from "renderer/components/pages/swap/swap/components/MoneroTransactionInfoBox"; export default function XmrRedeemInMempoolPage( state: TauriSwapProgressEventContent<"XmrRedeemInMempool">, @@ -9,7 +9,7 @@ export default function XmrRedeemInMempoolPage( const xmr_redeem_txid = state.xmr_redeem_txids[0] ?? null; return ( - <Box> + <> <DialogContentText> The swap was successful and the Monero has been sent to the following address(es). The swap is completed and you may exit the application now. @@ -77,6 +77,6 @@ export default function XmrRedeemInMempoolPage( /> <FeedbackInfoBox /> </Box> - </Box> + </> ); } diff --git a/src-gui/src/renderer/components/modal/swap/pages/exited/ProcessExitedPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/exited/ProcessExitedPage.tsx similarity index 94% rename from src-gui/src/renderer/components/modal/swap/pages/exited/ProcessExitedPage.tsx rename to src-gui/src/renderer/components/pages/swap/swap/exited/ProcessExitedPage.tsx index e24453d71c..5859c28019 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/exited/ProcessExitedPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/exited/ProcessExitedPage.tsx @@ -2,7 +2,7 @@ import { Box, DialogContentText } from "@mui/material"; import { TauriSwapProgressEvent } from "models/tauriModel"; import CliLogsBox from "renderer/components/other/RenderedCliLog"; import { useActiveSwapInfo, useActiveSwapLogs } from "store/hooks"; -import SwapStatePage from "../SwapStatePage"; +import SwapStatePage from "renderer/components/pages/swap/swap/SwapStatePage"; export default function ProcessExitedPage({ prevState, @@ -35,7 +35,7 @@ export default function ProcessExitedPage({ } return ( - <Box> + <> <DialogContentText> The swap was stopped but it has not been completed yet. Check the logs below for more information. The current GUI state is{" "} @@ -45,6 +45,6 @@ export default function ProcessExitedPage({ <Box> <CliLogsBox logs={logs} label="Logs relevant to the swap" /> </Box> - </Box> + </> ); } diff --git a/src-gui/src/renderer/components/modal/swap/pages/in_progress/BitcoinCancelledPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinCancelledPage.tsx similarity index 52% rename from src-gui/src/renderer/components/modal/swap/pages/in_progress/BitcoinCancelledPage.tsx rename to src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinCancelledPage.tsx index 11fec71159..c1b34ed5ec 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/in_progress/BitcoinCancelledPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinCancelledPage.tsx @@ -1,4 +1,4 @@ -import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle"; +import CircularProgressWithSubtitle from "renderer/components/pages/swap/swap/components/CircularProgressWithSubtitle"; export default function BitcoinCancelledPage() { return <CircularProgressWithSubtitle description="Refunding your Bitcoin" />; diff --git a/src-gui/src/renderer/components/modal/swap/pages/in_progress/BitcoinLockTxInMempoolPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinLockTxInMempoolPage.tsx similarity index 78% rename from src-gui/src/renderer/components/modal/swap/pages/in_progress/BitcoinLockTxInMempoolPage.tsx rename to src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinLockTxInMempoolPage.tsx index 0007cc39a6..153d40bdc0 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/in_progress/BitcoinLockTxInMempoolPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinLockTxInMempoolPage.tsx @@ -1,8 +1,6 @@ import { TauriSwapProgressEventContent } from "models/tauriModelExt"; import { formatConfirmations } from "utils/formatUtils"; -import BitcoinTransactionInfoBox from "../../BitcoinTransactionInfoBox"; -import SwapStatusAlert from "renderer/components/alert/SwapStatusAlert/SwapStatusAlert"; -import { useActiveSwapInfo } from "store/hooks"; +import BitcoinTransactionInfoBox from "renderer/components/pages/swap/swap/components/BitcoinTransactionInfoBox"; import { Box, DialogContentText } from "@mui/material"; // This is the number of blocks after which we consider the swap to be at risk of being unsuccessful @@ -12,10 +10,8 @@ export default function BitcoinLockTxInMempoolPage({ btc_lock_confirmations, btc_lock_txid, }: TauriSwapProgressEventContent<"BtcLockTxInMempool">) { - const swapInfo = useActiveSwapInfo(); - return ( - <Box> + <> {(btc_lock_confirmations === undefined || btc_lock_confirmations < BITCOIN_CONFIRMATIONS_WARNING_THRESHOLD) && ( <DialogContentText> @@ -32,10 +28,6 @@ export default function BitcoinLockTxInMempoolPage({ gap: "1rem", }} > - {btc_lock_confirmations !== undefined && - btc_lock_confirmations >= BITCOIN_CONFIRMATIONS_WARNING_THRESHOLD && ( - <SwapStatusAlert swap={swapInfo} isRunning={true} /> - )} <BitcoinTransactionInfoBox title="Bitcoin Lock Transaction" txId={btc_lock_txid} @@ -51,6 +43,6 @@ export default function BitcoinLockTxInMempoolPage({ } /> </Box> - </Box> + </> ); } diff --git a/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinRedeemedPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinRedeemedPage.tsx new file mode 100644 index 0000000000..6b972c8a30 --- /dev/null +++ b/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinRedeemedPage.tsx @@ -0,0 +1,5 @@ +import CircularProgressWithSubtitle from "renderer/components/pages/swap/swap/components/CircularProgressWithSubtitle"; + +export default function BitcoinRedeemedPage() { + return <CircularProgressWithSubtitle description="Redeeming your Monero" />; +} diff --git a/src-gui/src/renderer/components/modal/swap/pages/in_progress/CancelTimelockExpiredPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/in_progress/CancelTimelockExpiredPage.tsx similarity index 60% rename from src-gui/src/renderer/components/modal/swap/pages/in_progress/CancelTimelockExpiredPage.tsx rename to src-gui/src/renderer/components/pages/swap/swap/in_progress/CancelTimelockExpiredPage.tsx index c2f93bd729..803783ad82 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/in_progress/CancelTimelockExpiredPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/in_progress/CancelTimelockExpiredPage.tsx @@ -1,4 +1,4 @@ -import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle"; +import CircularProgressWithSubtitle from "../components/CircularProgressWithSubtitle"; export default function CancelTimelockExpiredPage() { return <CircularProgressWithSubtitle description="Cancelling the swap" />; diff --git a/src-gui/src/renderer/components/pages/swap/swap/in_progress/EncryptedSignatureSentPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/in_progress/EncryptedSignatureSentPage.tsx new file mode 100644 index 0000000000..7b1d81fcb1 --- /dev/null +++ b/src-gui/src/renderer/components/pages/swap/swap/in_progress/EncryptedSignatureSentPage.tsx @@ -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" /> + ); +} diff --git a/src-gui/src/renderer/components/modal/swap/pages/in_progress/ReceivedQuotePage.tsx b/src-gui/src/renderer/components/pages/swap/swap/in_progress/ReceivedQuotePage.tsx similarity index 93% rename from src-gui/src/renderer/components/modal/swap/pages/in_progress/ReceivedQuotePage.tsx rename to src-gui/src/renderer/components/pages/swap/swap/in_progress/ReceivedQuotePage.tsx index fa125dc2a2..f2d18a6d81 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/in_progress/ReceivedQuotePage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/in_progress/ReceivedQuotePage.tsx @@ -4,7 +4,7 @@ import { } from "store/hooks"; import CircularProgressWithSubtitle, { LinearProgressWithSubtitle, -} from "../../CircularProgressWithSubtitle"; +} from "../components/CircularProgressWithSubtitle"; export default function ReceivedQuotePage() { const syncProgress = useConservativeBitcoinSyncProgress(); diff --git a/src-gui/src/renderer/components/modal/swap/pages/in_progress/RedeemingMoneroPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/in_progress/RedeemingMoneroPage.tsx similarity index 63% rename from src-gui/src/renderer/components/modal/swap/pages/in_progress/RedeemingMoneroPage.tsx rename to src-gui/src/renderer/components/pages/swap/swap/in_progress/RedeemingMoneroPage.tsx index d9b9b5db73..a9d337b9f0 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/in_progress/RedeemingMoneroPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/in_progress/RedeemingMoneroPage.tsx @@ -1,4 +1,4 @@ -import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle"; +import CircularProgressWithSubtitle from "../components/CircularProgressWithSubtitle"; export default function RedeemingMoneroPage() { return ( diff --git a/src-gui/src/renderer/components/modal/swap/pages/in_progress/SwapSetupInflightPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/in_progress/SwapSetupInflightPage.tsx similarity index 81% rename from src-gui/src/renderer/components/modal/swap/pages/in_progress/SwapSetupInflightPage.tsx rename to src-gui/src/renderer/components/pages/swap/swap/in_progress/SwapSetupInflightPage.tsx index c826a449d7..9c7184be50 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/in_progress/SwapSetupInflightPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/in_progress/SwapSetupInflightPage.tsx @@ -5,10 +5,10 @@ import { TauriSwapProgressEventContent, } from "models/tauriModelExt"; import { SatsAmount, PiconeroAmount } from "renderer/components/other/Units"; -import { Box, Typography, Divider } from "@mui/material"; +import { Box, Typography, Divider, Theme } from "@mui/material"; import { useActiveSwapId, usePendingLockBitcoinApproval } from "store/hooks"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; -import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle"; +import CircularProgressWithSubtitle from "../components/CircularProgressWithSubtitle"; import CheckIcon from "@mui/icons-material/Check"; import ArrowRightAltIcon from "@mui/icons-material/ArrowRightAlt"; import TruncatedText from "renderer/components/other/TruncatedText"; @@ -56,13 +56,22 @@ export default function SwapSetupInflightPage({ // Display a loading spinner to the user for as long as the swap_setup request is in flight if (request == null) { return ( - <CircularProgressWithSubtitle - description={ - <> - Negotiating offer for <SatsAmount amount={btc_lock_amount} /> - </> - } - /> + <Box + sx={{ + height: 200, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <CircularProgressWithSubtitle + description={ + <> + Negotiating offer for <SatsAmount amount={btc_lock_amount} /> + </> + } + /> + </Box> ); } @@ -83,15 +92,15 @@ export default function SwapSetupInflightPage({ {/* Grid layout for perfect alignment */} <Box sx={{ - display: "grid", - gridTemplateColumns: "max-content auto max-content", + display: "flex", + flexDirection: { xs: "column", lg: "row" }, gap: "1.5rem", alignItems: "stretch", - justifyContent: "center", + justifyContent: "space-between", }} > {/* Row 1: Bitcoin box */} - <Box sx={{ height: "100%" }}> + <Box sx={{ height: "100%", flex: "0 0 auto" }}> <BitcoinMainBox btc_lock_amount={btc_lock_amount} btc_network_fee={btc_network_fee} @@ -110,7 +119,7 @@ export default function SwapSetupInflightPage({ </Box> {/* Row 1: Monero main box */} - <Box> + <Box sx={{ flex: "0 0 auto" }}> <MoneroMainBox monero_receive_pool={monero_receive_pool} xmr_receive_amount={xmr_receive_amount} @@ -120,38 +129,50 @@ export default function SwapSetupInflightPage({ <Box sx={{ - marginTop: 2, + marginTop: 4, display: "flex", - justifyContent: "center", - gap: 2, + flexDirection: "column", + alignItems: "center", + gap: 1.5, }} > - <PromiseInvokeButton - variant="text" - size="large" - sx={(theme) => ({ color: theme.palette.text.secondary })} - onInvoke={() => - resolveApproval(request.request_id, false as unknown as object) - } - displayErrorSnackbar - requiresContext - > - Deny - </PromiseInvokeButton> + <Box sx={{ display: "flex", justifyContent: "center", gap: 2 }}> + <PromiseInvokeButton + variant="text" + size="large" + sx={(theme) => ({ color: theme.palette.text.secondary })} + onInvoke={() => + resolveApproval(request.request_id, false as unknown as object) + } + displayErrorSnackbar + requiresContext + > + Deny + </PromiseInvokeButton> - <PromiseInvokeButton - variant="contained" - color="primary" - size="large" - onInvoke={() => - resolveApproval(request.request_id, true as unknown as object) - } - displayErrorSnackbar - requiresContext - endIcon={<CheckIcon />} + <PromiseInvokeButton + variant="contained" + color="primary" + size="large" + onInvoke={() => + resolveApproval(request.request_id, true as unknown as object) + } + displayErrorSnackbar + requiresContext + endIcon={<CheckIcon />} + > + {`Confirm`} + </PromiseInvokeButton> + </Box> + <Typography + variant="caption" + sx={{ + textAlign: "center", + color: (theme) => theme.palette.text.secondary, + }} > - {`Confirm (${timeLeft}s)`} - </PromiseInvokeButton> + {`Offer expires in ${timeLeft}s`} + </Typography> </Box> </Box> ); @@ -177,7 +198,15 @@ const BitcoinMainBox = ({ btc_lock_amount: number; btc_network_fee: number; }) => ( - <Box sx={{ position: "relative", height: "100%" }}> + <Box + sx={{ + position: "relative", + height: "100%", + display: "flex", + flexDirection: "column", + gap: 1, + }} + > <Box sx={{ display: "flex", @@ -188,10 +217,10 @@ const BitcoinMainBox = ({ gap: "0.5rem 1rem", borderColor: "warning.main", borderRadius: 1, + flexGrow: 1, backgroundColor: (theme) => theme.palette.warning.light + "10", background: (theme) => `linear-gradient(135deg, ${theme.palette.warning.light}20, ${theme.palette.warning.light}05)`, - height: "100%", // Match the height of the Monero box }} > <Typography @@ -217,10 +246,6 @@ const BitcoinMainBox = ({ {/* Network fee box attached to the bottom */} <Box sx={{ - position: "absolute", - bottom: "calc(-50%)", - left: "50%", - transform: "translateX(-50%)", padding: "0.25rem 0.75rem", backgroundColor: (theme) => theme.palette.warning.main, color: (theme) => theme.palette.warning.contrastText, @@ -271,7 +296,7 @@ const PoolBreakdown = ({ display: "flex", justifyContent: "flex-start", alignItems: "stretch", - padding: pool.percentage >= 0.05 ? 1.5 : 1.2, + padding: pool.percentage >= 0.05 ? 1.5 : "0.25rem 0.75rem", border: 1, borderColor: pool.percentage >= 0.05 ? "success.main" : "success.light", @@ -283,7 +308,6 @@ const PoolBreakdown = ({ width: "100%", // Ensure full width minWidth: 0, opacity: pool.percentage >= 0.05 ? 1 : 0.75, - transform: pool.percentage >= 0.05 ? "scale(1)" : "scale(0.95)", animation: pool.percentage >= 0.05 ? "poolPulse 2s ease-in-out infinite" @@ -308,6 +332,7 @@ const PoolBreakdown = ({ sx={{ display: "flex", flexDirection: "column", + justifyContent: "center", gap: 0.5, flex: "1 1 0", minWidth: 0, @@ -323,18 +348,20 @@ const PoolBreakdown = ({ > {pool.label === "user address" ? "Your Wallet" : pool.label} </Typography> - <Typography - variant="body2" - sx={{ - fontFamily: "monospace", - fontSize: "0.75rem", - color: (theme) => theme.palette.text.secondary, - }} - > - <TruncatedText truncateMiddle limit={15}> - {pool.address} - </TruncatedText> - </Typography> + {pool.label === "user address" && ( + <Typography + variant="body2" + sx={{ + fontFamily: "monospace", + fontSize: "0.75rem", + color: (theme) => theme.palette.text.secondary, + }} + > + <TruncatedText truncateMiddle limit={15}> + {pool.address} + </TruncatedText> + </Typography> + )} </Box> <Box sx={{ @@ -393,7 +420,7 @@ const MoneroMainBox = ({ ); return ( - <Box sx={{ position: "relative" }}> + <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> <Box sx={{ display: "flex", @@ -460,20 +487,10 @@ const MoneroMainBox = ({ </Box> {/* Secondary Monero content attached to the bottom */} - <Box - sx={{ - position: "absolute", - bottom: "calc(-100%)", - left: "50%", - transform: "translateX(-50%)", - zIndex: 1, - }} - > - <MoneroSecondaryContent - monero_receive_pool={monero_receive_pool} - xmr_receive_amount={xmr_receive_amount} - /> - </Box> + <MoneroSecondaryContent + monero_receive_pool={monero_receive_pool} + xmr_receive_amount={xmr_receive_amount} + /> </Box> ); }; @@ -491,8 +508,7 @@ const MoneroSecondaryContent = ({ // Arrow animation styling extracted for reuse const arrowSx = { fontSize: "3rem", - color: (theme: { palette: { primary: { main: string } } }) => - theme.palette.primary.main, + color: (theme: Theme) => theme.palette.primary.main, animation: "slideArrow 2s infinite", "@keyframes slideArrow": { "0%": { @@ -518,6 +534,7 @@ const AnimatedArrow = () => ( justifyContent: "center", alignSelf: "center", flex: "0 0 auto", + transform: { xs: "rotate(90deg)", lg: "rotate(0deg)" }, }} > <ArrowRightAltIcon sx={arrowSx} /> diff --git a/src-gui/src/renderer/components/modal/swap/pages/in_progress/WaitingForXmrConfirmationsBeforeRedeemPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/in_progress/WaitingForXmrConfirmationsBeforeRedeemPage.tsx similarity index 91% rename from src-gui/src/renderer/components/modal/swap/pages/in_progress/WaitingForXmrConfirmationsBeforeRedeemPage.tsx rename to src-gui/src/renderer/components/pages/swap/swap/in_progress/WaitingForXmrConfirmationsBeforeRedeemPage.tsx index 867522195b..6f6516c8ba 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/in_progress/WaitingForXmrConfirmationsBeforeRedeemPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/in_progress/WaitingForXmrConfirmationsBeforeRedeemPage.tsx @@ -1,6 +1,6 @@ import { Box, DialogContentText } from "@mui/material"; import { TauriSwapProgressEventContent } from "models/tauriModelExt"; -import MoneroTransactionInfoBox from "../../MoneroTransactionInfoBox"; +import MoneroTransactionInfoBox from "../components/MoneroTransactionInfoBox"; export default function WaitingForXmrConfirmationsBeforeRedeemPage({ xmr_lock_txid, diff --git a/src-gui/src/renderer/components/modal/swap/pages/in_progress/XmrLockInMempoolPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/in_progress/XmrLockInMempoolPage.tsx similarity index 84% rename from src-gui/src/renderer/components/modal/swap/pages/in_progress/XmrLockInMempoolPage.tsx rename to src-gui/src/renderer/components/pages/swap/swap/in_progress/XmrLockInMempoolPage.tsx index 1c04e2de96..293817c7bf 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/in_progress/XmrLockInMempoolPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/in_progress/XmrLockInMempoolPage.tsx @@ -1,7 +1,8 @@ import { Box, DialogContentText } from "@mui/material"; import { TauriSwapProgressEventContent } from "models/tauriModelExt"; import { formatConfirmations } from "utils/formatUtils"; -import MoneroTransactionInfoBox from "../../MoneroTransactionInfoBox"; +import MoneroTransactionInfoBox from "../components/MoneroTransactionInfoBox"; +import CancelButton from "../CancelButton"; export default function XmrLockTxInMempoolPage({ xmr_lock_tx_confirmations, @@ -11,7 +12,7 @@ export default function XmrLockTxInMempoolPage({ const additionalContent = `Confirmations: ${formatConfirmations(xmr_lock_tx_confirmations, xmr_lock_tx_target_confirmations)}`; return ( - <Box> + <> <DialogContentText> They have published their Monero lock transaction. The swap will proceed once the transaction has been confirmed. @@ -23,6 +24,8 @@ export default function XmrLockTxInMempoolPage({ additionalContent={additionalContent} loading /> - </Box> + + <CancelButton /> + </> ); } diff --git a/src-gui/src/renderer/components/modal/swap/pages/in_progress/XmrLockedPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/in_progress/XmrLockedPage.tsx similarity index 64% rename from src-gui/src/renderer/components/modal/swap/pages/in_progress/XmrLockedPage.tsx rename to src-gui/src/renderer/components/pages/swap/swap/in_progress/XmrLockedPage.tsx index 086126ac53..fa2079fdd0 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/in_progress/XmrLockedPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/in_progress/XmrLockedPage.tsx @@ -1,4 +1,4 @@ -import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle"; +import CircularProgressWithSubtitle from "../components/CircularProgressWithSubtitle"; export default function XmrLockedPage() { return ( diff --git a/src-gui/src/renderer/components/modal/swap/pages/init/InitPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/init/InitPage.tsx similarity index 89% rename from src-gui/src/renderer/components/modal/swap/pages/init/InitPage.tsx rename to src-gui/src/renderer/components/pages/swap/swap/init/InitPage.tsx index 84877b719c..30b8481dd4 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/init/InitPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/init/InitPage.tsx @@ -1,12 +1,11 @@ import { Box, Paper, Tab, Tabs, Typography } from "@mui/material"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import { useState } from "react"; -import RemainingFundsWillBeUsedAlert from "renderer/components/alert/RemainingFundsWillBeUsedAlert"; import BitcoinAddressTextField from "renderer/components/inputs/BitcoinAddressTextField"; import MoneroAddressTextField from "renderer/components/inputs/MoneroAddressTextField"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import { buyXmr } from "renderer/rpc"; -import { useAppSelector, useSettings } from "store/hooks"; +import { useSettings } from "store/hooks"; export default function InitPage() { const [redeemAddress, setRedeemAddress] = useState(""); @@ -17,12 +16,10 @@ export default function InitPage() { const [redeemAddressValid, setRedeemAddressValid] = useState(false); const [refundAddressValid, setRefundAddressValid] = useState(false); - const selectedMaker = useAppSelector((state) => state.makers.selectedMaker); const donationRatio = useSettings((s) => s.donateToDevelopment); async function init() { await buyXmr( - selectedMaker, useExternalRefundAddress ? refundAddress : null, redeemAddress, donationRatio, @@ -30,7 +27,7 @@ export default function InitPage() { } return ( - <Box> + <> <Box sx={{ display: "flex", @@ -38,7 +35,6 @@ export default function InitPage() { gap: 1.5, }} > - <RemainingFundsWillBeUsedAlert /> <MoneroAddressTextField label="Monero redeem address" address={redeemAddress} @@ -84,8 +80,7 @@ export default function InitPage() { <PromiseInvokeButton disabled={ (!refundAddressValid && useExternalRefundAddress) || - !redeemAddressValid || - !selectedMaker + !redeemAddressValid } variant="contained" color="primary" @@ -95,9 +90,9 @@ export default function InitPage() { onInvoke={init} displayErrorSnackbar > - Begin swap + Continue </PromiseInvokeButton> </Box> - </Box> + </> ); } diff --git a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/DepositAndChooseOfferPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/DepositAndChooseOfferPage.tsx new file mode 100644 index 0000000000..40b9a65e1f --- /dev/null +++ b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/DepositAndChooseOfferPage.tsx @@ -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> + ); +} diff --git a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerDiscoveryStatus.tsx b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerDiscoveryStatus.tsx new file mode 100644 index 0000000000..8af50ddbf6 --- /dev/null +++ b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerDiscoveryStatus.tsx @@ -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> + ); +} diff --git a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx new file mode 100644 index 0000000000..1dc38bbd78 --- /dev/null +++ b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx @@ -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> + ); +} diff --git a/src-gui/src/renderer/components/pages/wallet/WithdrawWidget.tsx b/src-gui/src/renderer/components/pages/wallet/WithdrawWidget.tsx index fdd5e807c0..047c5e0330 100644 --- a/src-gui/src/renderer/components/pages/wallet/WithdrawWidget.tsx +++ b/src-gui/src/renderer/components/pages/wallet/WithdrawWidget.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { SatsAmount } from "renderer/components/other/Units"; import { useAppSelector } from "store/hooks"; import BitcoinIcon from "../../icons/BitcoinIcon"; -import InfoBox from "../../modal/swap/InfoBox"; +import InfoBox from "../swap/swap/components/InfoBox"; import WithdrawDialog from "../../modal/wallet/WithdrawDialog"; import WalletRefreshButton from "./WalletRefreshButton"; diff --git a/src-gui/src/renderer/components/theme.tsx b/src-gui/src/renderer/components/theme.tsx index d3fc65b505..351827d903 100644 --- a/src-gui/src/renderer/components/theme.tsx +++ b/src-gui/src/renderer/components/theme.tsx @@ -13,6 +13,15 @@ const baseTheme: ThemeOptions = { fontFamily: "monospace", }, }, + breakpoints: { + values: { + xs: 0, + sm: 600, + md: 900, + lg: 1000, + xl: 1536, + }, + }, components: { MuiButton: { styleOverrides: { diff --git a/src-gui/src/renderer/rpc.ts b/src-gui/src/renderer/rpc.ts index c24bc5dac2..816fd12375 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -29,6 +29,7 @@ import { ResolveApprovalResponse, RedactArgs, RedactResponse, + GetCurrentSwapResponse, LabeledMoneroAddress, } from "models/tauriModel"; import { rpcSetBalance, rpcSetSwapInfo } from "store/features/rpcSlice"; @@ -174,11 +175,22 @@ export async function withdrawBtc(address: string): Promise<string> { } export async function buyXmr( - seller: Maker, bitcoin_change_address: string | null, monero_receive_address: string, donation_percentage: DonateToDevelopmentTip, ) { + // Get all available makers from the Redux store + const state = store.getState(); + const allMakers = [ + ...(state.makers.registry.makers || []), + ...state.makers.rendezvous.makers, + ]; + + // Convert all makers to multiaddr format + const sellers = allMakers.map((maker) => + providerToConcatenatedMultiAddr(maker), + ); + const address_pool: LabeledMoneroAddress[] = []; if (donation_percentage !== false) { const donation_address = isTestnet() @@ -206,7 +218,8 @@ export async function buyXmr( } await invoke<BuyXmrArgs, BuyXmrResponse>("buy_xmr", { - seller: providerToConcatenatedMultiAddr(seller), + rendezvous_points: PRESET_RENDEZVOUS_POINTS, + sellers, monero_receive_pool: address_pool, bitcoin_change_address, }); @@ -222,6 +235,10 @@ export async function suspendCurrentSwap() { await invokeNoArgs<SuspendCurrentSwapResponse>("suspend_current_swap"); } +export async function getCurrentSwapId() { + return await invokeNoArgs<GetCurrentSwapResponse>("get_current_swap"); +} + export async function getMoneroRecoveryKeys( swapId: string, ): Promise<MoneroRecoveryResponse> { diff --git a/src-gui/src/store/combinedReducer.ts b/src-gui/src/store/combinedReducer.ts index a81b278ba5..31bd2ebdf8 100644 --- a/src-gui/src/store/combinedReducer.ts +++ b/src-gui/src/store/combinedReducer.ts @@ -3,7 +3,6 @@ import makersSlice from "./features/makersSlice"; import ratesSlice from "./features/ratesSlice"; import rpcSlice from "./features/rpcSlice"; import swapReducer from "./features/swapSlice"; -import torSlice from "./features/torSlice"; import settingsSlice from "./features/settingsSlice"; import nodesSlice from "./features/nodesSlice"; import conversationsSlice from "./features/conversationsSlice"; @@ -12,7 +11,6 @@ import poolSlice from "./features/poolSlice"; export const reducers = { swap: swapReducer, makers: makersSlice, - tor: torSlice, rpc: rpcSlice, alerts: alertsSlice, rates: ratesSlice, diff --git a/src-gui/src/store/features/makersSlice.ts b/src-gui/src/store/features/makersSlice.ts index a60783825c..16221b4d82 100644 --- a/src-gui/src/store/features/makersSlice.ts +++ b/src-gui/src/store/features/makersSlice.ts @@ -4,7 +4,6 @@ import { SellerStatus } from "models/tauriModel"; import { getStubTestnetMaker } from "store/config"; import { rendezvousSellerToMakerStatus } from "utils/conversionUtils"; import { isMakerOutdated } from "utils/multiAddrUtils"; -import { sortMakerList } from "utils/sortUtils"; const stubTestnetMaker = getStubTestnetMaker(); @@ -48,10 +47,10 @@ function selectNewSelectedMaker( } // Otherwise we'd prefer to switch to a provider that has the newest version - const providers = sortMakerList([ + const providers = [ ...(slice.registry.makers ?? []), ...(slice.rendezvous.makers ?? []), - ]); + ]; return providers.at(0) || null; } @@ -86,7 +85,6 @@ export const makersSlice = createSlice({ }); // Sort the provider list and select a new provider if needed - slice.rendezvous.makers = sortMakerList(slice.rendezvous.makers); slice.selectedMaker = selectNewSelectedMaker(slice); }, setRegistryMakers(slice, action: PayloadAction<ExtendedMakerStatus[]>) { @@ -95,7 +93,6 @@ export const makersSlice = createSlice({ } // Sort the provider list and select a new provider if needed - slice.registry.makers = sortMakerList(action.payload); slice.selectedMaker = selectNewSelectedMaker(slice); }, registryConnectionFailed(slice) { diff --git a/src-gui/src/store/features/torSlice.ts b/src-gui/src/store/features/torSlice.ts deleted file mode 100644 index f0aa411516..0000000000 --- a/src-gui/src/store/features/torSlice.ts +++ /dev/null @@ -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; diff --git a/src-gui/src/store/hooks.ts b/src-gui/src/store/hooks.ts index 1f7d0a9fc2..2c71aa059e 100644 --- a/src-gui/src/store/hooks.ts +++ b/src-gui/src/store/hooks.ts @@ -8,6 +8,9 @@ import { isPendingSeedSelectionApprovalEvent, PendingApprovalRequest, PendingLockBitcoinApprovalRequest, + PendingSelectMakerApprovalRequest, + isPendingSelectMakerApprovalEvent, + haveFundsBeenLocked, PendingSeedSelectionApprovalRequest, } from "models/tauriModelExt"; import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; @@ -18,7 +21,6 @@ import { isCliLogRelatedToSwap } from "models/cliModel"; import { SettingsState } from "./features/settingsSlice"; import { NodesSlice } from "./features/nodesSlice"; import { RatesState } from "./features/ratesSlice"; -import { sortMakerList } from "utils/sortUtils"; import { TauriBackgroundProgress, TauriBitcoinSyncProgress, @@ -56,7 +58,7 @@ export function useResumeableSwapsCountExcludingPunished() { ); } -/// Returns true if we have a swap that is running +/// Returns true if we have any swap that is running export function useIsSwapRunning() { return useAppSelector( (state) => @@ -64,6 +66,46 @@ export function useIsSwapRunning() { ); } +/// Returns true if we have a swap that is running and +/// that swap has any funds locked +export function useIsSwapRunningAndHasFundsLocked() { + const swapInfo = useActiveSwapInfo(); + const swapTauriState = useAppSelector( + (state) => state.swap.state?.curr ?? null, + ); + + // If the swap is in the Released state, we return false + if (swapTauriState?.type === "Released") { + return false; + } + + // If the tauri state tells us that funds have been locked, we return true + if (haveFundsBeenLocked(swapTauriState)) { + return true; + } + + // If we have a database entry (swapInfo) for this swap, we return true + if (swapInfo != null) { + return true; + } + + return false; +} + +/// Returns true if we have a swap that is running +export function useIsSpecificSwapRunning(swapId: string | null) { + if (swapId == null) { + return false; + } + + return useAppSelector( + (state) => + state.swap.state !== null && + state.swap.state.swapId === swapId && + state.swap.state.curr.type !== "Released", + ); +} + export function useIsContextAvailable() { return useAppSelector( (state) => state.rpc.status === TauriContextStatusEvent.Available, @@ -103,9 +145,7 @@ export function useAllMakers() { return useAppSelector((state) => { const registryMakers = state.makers.registry.makers || []; const listSellersMakers = state.makers.rendezvous.makers || []; - const all = [...registryMakers, ...listSellersMakers]; - - return sortMakerList(all); + return [...registryMakers, ...listSellersMakers]; }); } @@ -167,6 +207,11 @@ export function usePendingLockBitcoinApproval(): PendingLockBitcoinApprovalReque return approvals.filter((c) => isPendingLockBitcoinApprovalEvent(c)); } +export function usePendingSelectMakerApproval(): PendingSelectMakerApprovalRequest[] { + const approvals = usePendingApprovals(); + return approvals.filter((c) => isPendingSelectMakerApprovalEvent(c)); +} + export function usePendingSeedSelectionApproval(): PendingSeedSelectionApprovalRequest[] { const approvals = usePendingApprovals(); return approvals.filter((c) => isPendingSeedSelectionApprovalEvent(c)); diff --git a/src-gui/src/store/middleware/storeListener.ts b/src-gui/src/store/middleware/storeListener.ts index 4828cc7683..e9a24f1422 100644 --- a/src-gui/src/store/middleware/storeListener.ts +++ b/src-gui/src/store/middleware/storeListener.ts @@ -1,4 +1,5 @@ import { createListenerMiddleware } from "@reduxjs/toolkit"; +import { throttle, debounce } from "lodash"; import { getAllSwapInfos, checkBitcoinBalance, @@ -22,6 +23,33 @@ import { } from "store/features/conversationsSlice"; import { TauriContextStatusEvent } from "models/tauriModel"; +// Create a Map to store throttled functions per swap_id +const throttledGetSwapInfoFunctions = new Map< + string, + ReturnType<typeof throttle> +>(); + +// Function to get or create a throttled getSwapInfo for a specific swap_id +const getThrottledSwapInfoUpdater = (swapId: string) => { + if (!throttledGetSwapInfoFunctions.has(swapId)) { + // Create a throttled function that executes at most once every 2 seconds + // but will wait for 3 seconds of quiet during rapid calls (using debounce) + const debouncedGetSwapInfo = debounce(() => { + logger.debug(`Executing getSwapInfo for swap ${swapId}`); + getSwapInfo(swapId); + }, 3000); // 3 seconds debounce for rapid calls + + const throttledFunction = throttle(debouncedGetSwapInfo, 2000, { + leading: true, // Execute immediately on first call + trailing: true, // Execute on trailing edge if needed + }); + + throttledGetSwapInfoFunctions.set(swapId, throttledFunction); + } + + return throttledGetSwapInfoFunctions.get(swapId)!; +}; + export function createMainListeners() { const listener = createListenerMiddleware(); @@ -57,11 +85,14 @@ export function createMainListeners() { await checkBitcoinBalance(); } - // Update the swap info + // Update the swap info using throttled function logger.info( - "Swap progress event received, updating swap info from database...", + "Swap progress event received, scheduling throttled swap info update...", + ); + const throttledUpdater = getThrottledSwapInfoUpdater( + action.payload.swap_id, ); - await getSwapInfo(action.payload.swap_id); + throttledUpdater(); }, }); diff --git a/src-gui/src/utils/sortUtils.ts b/src-gui/src/utils/sortUtils.ts index a44bc0c7c2..5ba942b8bd 100644 --- a/src-gui/src/utils/sortUtils.ts +++ b/src-gui/src/utils/sortUtils.ts @@ -1,33 +1,57 @@ -import { ExtendedMakerStatus } from "models/apiModel"; -import { isMakerOnCorrectNetwork, isMakerOutdated } from "./multiAddrUtils"; +import { + PendingSelectMakerApprovalRequest, + SortableQuoteWithAddress, +} from "models/tauriModelExt"; +import { QuoteWithAddress } from "models/tauriModel"; +import { isMakerVersionOutdated } from "./multiAddrUtils"; import _ from "lodash"; -export function sortMakerList(list: ExtendedMakerStatus[]) { +export function sortApprovalsAndKnownQuotes( + pendingSelectMakerApprovals: PendingSelectMakerApprovalRequest[], + known_quotes: QuoteWithAddress[], +) { + const sortableQuotes = pendingSelectMakerApprovals.map((approval) => { + return { + ...approval.request.content.maker, + expiration_ts: + approval.request_status.state === "Pending" + ? approval.request_status.content.expiration_ts + : undefined, + request_id: approval.request_id, + } as SortableQuoteWithAddress; + }); + + sortableQuotes.push( + ...known_quotes.map((quote) => ({ + ...quote, + request_id: null, + })), + ); + + return sortMakerApprovals(sortableQuotes); +} + +export function sortMakerApprovals(list: SortableQuoteWithAddress[]) { return ( _(list) - // Filter out makers that are on the wrong network (testnet / mainnet) - .filter(isMakerOnCorrectNetwork) - // Sort by criteria .orderBy( [ // Prefer makers that have a 'version' attribute // If we don't have a version, we cannot clarify if it's outdated or not (m) => (m.version ? 0 : 1), // Prefer makers that are not outdated - (m) => (isMakerOutdated(m) ? 1 : 0), - // Prefer makers that have a relevancy score - (m) => (m.relevancy == null ? 1 : 0), - // Prefer makers with a higher relevancy score - (m) => -(m.relevancy ?? 0), + (m) => (isMakerVersionOutdated(m.version) ? 1 : 0), // Prefer makers with a minimum quantity > 0 - (m) => ((m.minSwapAmount ?? 0) > 0 ? 0 : 1), + (m) => ((m.quote.min_quantity ?? 0) > 0 ? 0 : 1), + // Prefer approvals over actual quotes + (m) => (m.request_id ? 0 : 1), // Prefer makers with a lower price - (m) => m.price, + (m) => m.quote.price, ], ["asc", "asc", "asc", "asc", "asc"], ) // Remove duplicate makers - .uniqBy((m) => m.peerId) + .uniqBy((m) => m.peer_id) .value() ); } diff --git a/src-gui/yarn.lock b/src-gui/yarn.lock index e1a98197b9..a077c03717 100644 --- a/src-gui/yarn.lock +++ b/src-gui/yarn.lock @@ -1554,6 +1554,11 @@ bl@^1.2.1: readable-stream "^2.3.5" safe-buffer "^5.1.1" +boring-avatars@^1.11.2: + version "1.11.2" + resolved "https://registry.yarnpkg.com/boring-avatars/-/boring-avatars-1.11.2.tgz#365e0b765fb0065ca0cb2fd20c200674d0a9ded6" + integrity sha512-3+wkwPeObwS4R37FGXMYViqc4iTrIRj5yzfX9Qy4mnpZ26sX41dGMhsAgmKks1r/uufY1pl4vpgzMWHYfJRb2A== + brace-expansion@^1.1.7: version "1.1.12" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d91b466aa7..c48bd4116e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,10 +9,10 @@ use swap::cli::{ request::{ BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, CheckElectrumNodeArgs, CheckElectrumNodeResponse, CheckMoneroNodeArgs, CheckMoneroNodeResponse, CheckSeedArgs, - CheckSeedResponse, ExportBitcoinWalletArgs, GetDataDirArgs, GetHistoryArgs, - GetLogsArgs, GetMoneroAddressesArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, - ListSellersArgs, MoneroRecoveryArgs, RedactArgs, ResolveApprovalArgs, ResumeSwapArgs, - SuspendCurrentSwapArgs, WithdrawBtcArgs, + CheckSeedResponse, ExportBitcoinWalletArgs, GetCurrentSwapArgs, GetDataDirArgs, + GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, GetSwapInfoArgs, + GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs, + ResolveApprovalArgs, ResumeSwapArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs, }, tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings}, Context, ContextBuilder, @@ -195,6 +195,7 @@ pub fn run() { check_monero_node, check_electrum_node, get_wallet_descriptor, + get_current_swap, get_data_dir, resolve_approval_request, redact, @@ -249,6 +250,7 @@ tauri_command!(get_swap_info, GetSwapInfoArgs); tauri_command!(get_swap_infos_all, GetSwapInfosAllArgs, no_args); tauri_command!(get_history, GetHistoryArgs, no_args); tauri_command!(get_monero_addresses, GetMoneroAddressesArgs, no_args); +tauri_command!(get_current_swap, GetCurrentSwapArgs, no_args); /// Here we define Tauri commands whose implementation is not delegated to the Request trait #[tauri::command] diff --git a/swap/Cargo.toml b/swap/Cargo.toml index 555dd1dbfc..1b70a3d046 100644 --- a/swap/Cargo.toml +++ b/swap/Cargo.toml @@ -51,7 +51,6 @@ monero-sys = { path = "../monero-sys" } once_cell = "1.19" pem = "3.0" proptest = "1" -qrcode = "0.14" rand = "0.8" rand_chacha = "0.3" regex = "1.10" @@ -72,9 +71,9 @@ strum = { version = "0.26", features = ["derive"] } tauri = { version = "2.0", features = ["config-json5"], optional = true, default-features = false } thiserror = "1" time = "0.3" -tokio = { version = "1", features = ["rt-multi-thread", "time", "macros", "sync", "process", "fs", "net", "parking_lot"] } +tokio = { version = "1", features = ["rt-multi-thread", "time", "macros", "sync", "process", "fs", "net", "parking_lot", "rt"] } tokio-tungstenite = { version = "0.15", features = ["rustls-tls"] } -tokio-util = { version = "0.7", features = ["io", "codec"] } +tokio-util = { version = "0.7", features = ["io", "codec", "rt"] } toml = "0.8" tor-rtcompat = { version = "0.25.0", features = ["tokio"] } tower = { version = "0.4.13", features = ["full"] } diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index ff0084e182..ff9cd16681 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -1,37 +1,40 @@ use super::tauri_bindings::TauriHandle; -use crate::bitcoin::{wallet, CancelTimelock, ExpiredTimelocks, PunishTimelock, TxLock}; -use crate::cli::api::tauri_bindings::{TauriEmitter, TauriSwapProgressEvent}; +use crate::bitcoin::{wallet, CancelTimelock, ExpiredTimelocks, PunishTimelock}; +use crate::cli::api::tauri_bindings::{SelectMakerDetails, TauriEmitter, TauriSwapProgressEvent}; use crate::cli::api::Context; -use crate::cli::list_sellers::{QuoteWithAddress, UnreachableSeller}; +use crate::cli::list_sellers::{list_sellers_init, QuoteWithAddress, UnreachableSeller}; use crate::cli::{list_sellers as list_sellers_impl, EventLoop, SellerStatus}; use crate::common::{get_logs, redact}; use crate::libp2p_ext::MultiAddrExt; use crate::monero::wallet_rpc::MoneroDaemon; use crate::monero::MoneroAddressPool; use crate::network::quote::{BidQuote, ZeroQuoteReceived}; +use crate::network::rendezvous::XmrBtcNamespace; use crate::network::swarm; use crate::protocol::bob::{BobState, Swap}; -use crate::protocol::{bob, State}; +use crate::protocol::{bob, Database, State}; use crate::{bitcoin, cli, monero}; use ::bitcoin::address::NetworkUnchecked; use ::bitcoin::Txid; use ::monero::Network; use anyhow::{bail, Context as AnyContext, Result}; +use arti_client::TorClient; +use futures::stream::FuturesUnordered; +use futures::StreamExt; use libp2p::core::Multiaddr; -use libp2p::PeerId; +use libp2p::{identity, PeerId}; use monero_seed::{Language, Seed as MoneroSeed}; use once_cell::sync::Lazy; -use qrcode::render::unicode; -use qrcode::QrCode; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::cmp::min; use std::convert::TryInto; use std::future::Future; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use thiserror::Error; +use tokio_util::task::AbortOnDropHandle; +use tor_rtcompat::tokio::TokioRustlsRuntime; use tracing::debug_span; use tracing::Instrument; use tracing::Span; @@ -58,8 +61,10 @@ fn get_swap_tracing_span(swap_id: Uuid) -> Span { #[typeshare] #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct BuyXmrArgs { - #[typeshare(serialized_as = "string")] - pub seller: Multiaddr, + #[typeshare(serialized_as = "Vec<string>")] + pub rendezvous_points: Vec<Multiaddr>, + #[typeshare(serialized_as = "Vec<string>")] + pub sellers: Vec<Multiaddr>, #[typeshare(serialized_as = "Option<string>")] pub bitcoin_change_address: Option<bitcoin::Address<NetworkUnchecked>>, pub monero_receive_pool: MoneroAddressPool, @@ -310,8 +315,9 @@ pub struct SuspendCurrentSwapArgs; #[typeshare] #[derive(Serialize, Deserialize, Debug)] pub struct SuspendCurrentSwapResponse { - #[typeshare(serialized_as = "string")] - pub swap_id: Uuid, + // If no swap was running, we still return Ok(...) but this is set to None + #[typeshare(serialized_as = "Option<string>")] + pub swap_id: Option<Uuid>, } impl Request for SuspendCurrentSwapArgs { @@ -322,10 +328,19 @@ impl Request for SuspendCurrentSwapArgs { } } +#[typeshare] +#[derive(Debug, Serialize, Deserialize)] pub struct GetCurrentSwapArgs; +#[typeshare] +#[derive(Serialize, Deserialize, Debug)] +pub struct GetCurrentSwapResponse { + #[typeshare(serialized_as = "Option<string>")] + pub swap_id: Option<Uuid>, +} + impl Request for GetCurrentSwapArgs { - type Response = serde_json::Value; + type Response = GetCurrentSwapResponse; async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> { get_current_swap(ctx).await @@ -463,9 +478,12 @@ pub async fn suspend_current_swap(context: Arc<Context>) -> Result<SuspendCurren if let Some(id_value) = swap_id { context.swap_lock.send_suspend_signal().await?; - Ok(SuspendCurrentSwapResponse { swap_id: id_value }) + Ok(SuspendCurrentSwapResponse { + swap_id: Some(id_value), + }) } else { - bail!("No swap is currently running") + // If no swap was running, we still return Ok(...) with None + Ok(SuspendCurrentSwapResponse { swap_id: None }) } } @@ -593,8 +611,11 @@ pub async fn buy_xmr( swap_id: Uuid, context: Arc<Context>, ) -> Result<BuyXmrResponse, anyhow::Error> { + let _span = get_swap_tracing_span(swap_id); + let BuyXmrArgs { - seller, + rendezvous_points, + sellers, bitcoin_change_address, monero_receive_pool, } = buy_xmr; @@ -635,13 +656,103 @@ pub async fn buy_xmr( let env_config = context.config.env_config; let seed = context.config.seed.clone().context("Could not get seed")?; - let seller_peer_id = seller - .extract_peer_id() - .context("Seller address must contain peer ID")?; + // Prepare variables for the quote fetching process + let identity = seed.derive_libp2p_identity(); + let namespace = context.config.namespace; + let tor_client = context.tor_client.clone(); + let db = Some(context.db.clone()); + let tauri_handle = context.tauri_handle.clone(); + + // Wait for the user to approve a seller and to deposit coins + // Calling determine_btc_to_swap + let address_len = bitcoin_wallet.new_address().await?.script_pubkey().len(); + + let bitcoin_wallet_for_closures = Arc::clone(&bitcoin_wallet); + + // Clone bitcoin_change_address before moving it in the emit call + let bitcoin_change_address_for_spawn = bitcoin_change_address.clone(); + let rendezvous_points_clone = rendezvous_points.clone(); + let sellers_clone = sellers.clone(); + + // Acquire the lock before the user has selected a maker and we already have funds in the wallet + // because we need to be able to cancel the determine_btc_to_swap(..) + context.swap_lock.acquire_swap_lock(swap_id).await?; + + let (seller_multiaddr, seller_peer_id, quote, tx_lock_amount, tx_lock_fee) = tokio::select! { + result = determine_btc_to_swap( + move || { + let rendezvous_points = rendezvous_points_clone.clone(); + let sellers = sellers_clone.clone(); + let namespace = namespace; + let identity = identity.clone(); + let db = db.clone(); + let tor_client = tor_client.clone(); + let tauri_handle = tauri_handle.clone(); + + Box::pin(async move { + fetch_quotes_task( + rendezvous_points, + namespace, + sellers, + identity, + db, + tor_client, + tauri_handle, + ).await + }) + }, + bitcoin_wallet.new_address(), + { + let wallet = Arc::clone(&bitcoin_wallet_for_closures); + move || { + let w = wallet.clone(); + async move { w.balance().await } + } + }, + { + let wallet = Arc::clone(&bitcoin_wallet_for_closures); + move || { + let w = wallet.clone(); + async move { w.max_giveable(address_len).await } + } + }, + { + let wallet = Arc::clone(&bitcoin_wallet_for_closures); + move || { + let w = wallet.clone(); + async move { w.sync().await } + } + }, + context.tauri_handle.clone(), + swap_id, + |quote_with_address| { + let tauri_handle = context.tauri_handle.clone(); + Box::new(async move { + let details = SelectMakerDetails { + swap_id, + btc_amount_to_swap: quote_with_address.quote.max_quantity, + maker: quote_with_address, + }; + + tauri_handle.request_maker_selection(details, 300).await + }) as Box<dyn Future<Output = Result<bool>> + Send> + }, + ) => { + result? + } + _ = context.swap_lock.listen_for_swap_force_suspension() => { + context.swap_lock.release_swap_lock().await.expect("Shutdown signal received but failed to release swap lock. The swap process has been terminated but the swap lock is still active."); + context.tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); + bail!("Shutdown signal received"); + }, + }; + + // Insert the peer_id into the database + context.db.insert_peer_id(swap_id, seller_peer_id).await?; context .db - .insert_address(seller_peer_id, seller.clone()) + .insert_address(seller_peer_id, seller_multiaddr.clone()) .await?; let behaviour = cli::Behaviour::new( @@ -658,7 +769,7 @@ pub async fn buy_xmr( ) .await?; - swarm.add_peer_address(seller_peer_id, seller); + swarm.add_peer_address(seller_peer_id, seller_multiaddr.clone()); context .db @@ -667,57 +778,19 @@ pub async fn buy_xmr( tracing::debug!(peer_id = %swarm.local_peer_id(), "Network layer initialized"); - context.swap_lock.acquire_swap_lock(swap_id).await?; - - context - .tauri_handle - .emit_swap_progress_event(swap_id, TauriSwapProgressEvent::RequestingQuote); - - let initialize_swap = tokio::select! { - biased; - _ = context.swap_lock.listen_for_swap_force_suspension() => { - tracing::debug!("Shutdown signal received, exiting"); - context.swap_lock.release_swap_lock().await.expect("Shutdown signal received but failed to release swap lock. The swap process has been terminated but the swap lock is still active."); - - context.tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); - - bail!("Shutdown signal received"); - }, - result = async { - let (event_loop, mut event_loop_handle) = - EventLoop::new(swap_id, swarm, seller_peer_id, context.db.clone())?; - let event_loop = tokio::spawn(event_loop.run().in_current_span()); - - let bid_quote = event_loop_handle.request_quote().await?; - - Ok::<_, anyhow::Error>((event_loop, event_loop_handle, bid_quote)) - } => { - result - }, - }; - - let (event_loop, event_loop_handle, bid_quote) = match initialize_swap { - Ok(result) => result, - Err(error) => { - tracing::error!(%swap_id, "Swap initialization failed: {:#}", error); - - context - .swap_lock - .release_swap_lock() - .await - .expect("Could not release swap lock"); - - context - .tauri_handle - .emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); + context.tauri_handle.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::ReceivedQuote(quote.clone()), + ); - bail!(error); - } - }; + // Now create the event loop we use for the swap + let (event_loop, event_loop_handle) = + EventLoop::new(swap_id, swarm, seller_peer_id, context.db.clone())?; + let event_loop = tokio::spawn(event_loop.run().in_current_span()); context .tauri_handle - .emit_swap_progress_event(swap_id, TauriSwapProgressEvent::ReceivedQuote(bid_quote)); + .emit_swap_progress_event(swap_id, TauriSwapProgressEvent::ReceivedQuote(quote)); context.tasks.clone().spawn(async move { tokio::select! { @@ -730,6 +803,7 @@ pub async fn buy_xmr( bail!("Shutdown signal received"); }, + event_loop_result = event_loop => { match event_loop_result { Ok(_) => { @@ -741,36 +815,6 @@ pub async fn buy_xmr( } }, swap_result = async { - let max_givable = || async { - let (amount, fee) = bitcoin_wallet.max_giveable(TxLock::script_size()).await?; - Ok((amount, fee)) - }; - - let determine_amount = determine_btc_to_swap( - context.config.json, - bid_quote, - bitcoin_wallet.new_address(), - || bitcoin_wallet.balance(), - max_givable, - || bitcoin_wallet.sync(), - context.tauri_handle.clone(), - Some(swap_id) - ); - - let (tx_lock_amount, tx_lock_fee) = match determine_amount.await { - Ok(val) => val, - Err(error) => match error.downcast::<ZeroQuoteReceived>() { - Ok(_) => { - bail!("Seller's XMR balance is currently too low to initiate a swap, please try again later") - } - Err(other) => bail!(other), - }, - }; - - tracing::info!(%tx_lock_amount, %tx_lock_fee, "Determined swap amount"); - - context.db.insert_peer_id(swap_id, seller_peer_id).await?; - let swap = Swap::new( Arc::clone(&context.db), swap_id, @@ -779,7 +823,7 @@ pub async fn buy_xmr( env_config, event_loop_handle, monero_receive_pool.clone(), - bitcoin_change_address, + bitcoin_change_address_for_spawn, tx_lock_amount, tx_lock_fee ).with_event_emitter(context.tauri_handle.clone()); @@ -809,10 +853,7 @@ pub async fn buy_xmr( Ok::<_, anyhow::Error>(()) }.in_current_span()).await; - Ok(BuyXmrResponse { - swap_id, - quote: bid_quote, - }) + Ok(BuyXmrResponse { swap_id, quote }) } #[tracing::instrument(fields(method = "resume_swap"), skip(context))] @@ -1202,10 +1243,9 @@ pub async fn monero_recovery( } #[tracing::instrument(fields(method = "get_current_swap"), skip(context))] -pub async fn get_current_swap(context: Arc<Context>) -> Result<serde_json::Value> { - Ok(json!({ - "swap_id": context.swap_lock.get_current_swap_id().await, - })) +pub async fn get_current_swap(context: Arc<Context>) -> Result<GetCurrentSwapResponse> { + let swap_id = context.swap_lock.get_current_swap_id().await; + Ok(GetCurrentSwapResponse { swap_id }) } pub async fn resolve_approval_request( @@ -1225,133 +1265,270 @@ pub async fn resolve_approval_request( Ok(ResolveApprovalResponse { success: true }) } -fn qr_code(value: &impl ToString) -> Result<String> { - let code = QrCode::new(value.to_string())?; - let qr_code = code - .render::<unicode::Dense1x2>() - .dark_color(unicode::Dense1x2::Light) - .light_color(unicode::Dense1x2::Dark) - .build(); - Ok(qr_code) +pub async fn fetch_quotes_task( + rendezvous_points: Vec<Multiaddr>, + namespace: XmrBtcNamespace, + sellers: Vec<Multiaddr>, + identity: identity::Keypair, + db: Option<Arc<dyn Database + Send + Sync>>, + tor_client: Option<Arc<TorClient<TokioRustlsRuntime>>>, + tauri_handle: Option<TauriHandle>, +) -> Result<( + tokio::task::JoinHandle<()>, + ::tokio::sync::watch::Receiver<Vec<SellerStatus>>, +)> { + let (tx, rx) = ::tokio::sync::watch::channel(Vec::new()); + + let rendezvous_nodes: Vec<_> = rendezvous_points + .iter() + .filter_map(|addr| addr.split_peer_id()) + .collect(); + + let fetch_fn = list_sellers_init( + rendezvous_nodes, + namespace, + tor_client, + identity, + db, + tauri_handle, + Some(tx.clone()), + sellers, + ) + .await?; + + let handle = tokio::task::spawn(async move { + loop { + let sellers = fetch_fn().await; + let _ = tx.send(sellers); + + tokio::time::sleep(std::time::Duration::from_secs(90)).await; + } + }); + + Ok((handle, rx)) +} + +// TODO: Let this take a refresh interval as an argument +pub async fn refresh_wallet_task<FMG, TMG, FB, TB, FS, TS>( + max_giveable_fn: FMG, + balance_fn: FB, + sync_fn: FS, +) -> Result<( + tokio::task::JoinHandle<()>, + ::tokio::sync::watch::Receiver<(bitcoin::Amount, bitcoin::Amount)>, +)> +where + TMG: Future<Output = Result<(bitcoin::Amount, bitcoin::Amount)>> + Send + 'static, + FMG: Fn() -> TMG + Send + 'static, + TB: Future<Output = Result<bitcoin::Amount>> + Send + 'static, + FB: Fn() -> TB + Send + 'static, + TS: Future<Output = Result<()>> + Send + 'static, + FS: Fn() -> TS + Send + 'static, +{ + let (tx, rx) = ::tokio::sync::watch::channel((bitcoin::Amount::ZERO, bitcoin::Amount::ZERO)); + + let handle = tokio::task::spawn(async move { + loop { + // Sync wallet before checking balance + let _ = sync_fn().await; + + if let (Ok(balance), Ok((max_giveable, _fee))) = + (balance_fn().await, max_giveable_fn().await) + { + let _ = tx.send((balance, max_giveable)); + } + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + }); + + Ok((handle, rx)) } #[allow(clippy::too_many_arguments)] -pub async fn determine_btc_to_swap<FB, TB, FMG, TMG, FS, TS>( - json: bool, - bid_quote: BidQuote, +pub async fn determine_btc_to_swap<FB, TB, FMG, TMG, FS, TS, FQ>( + quote_fetch_tasks: FQ, + // TODO: Shouldn't this be a function? get_new_address: impl Future<Output = Result<bitcoin::Address>>, balance: FB, max_giveable_fn: FMG, sync: FS, event_emitter: Option<TauriHandle>, - swap_id: Option<Uuid>, -) -> Result<(bitcoin::Amount, bitcoin::Amount)> + swap_id: Uuid, + request_approval: impl Fn(QuoteWithAddress) -> Box<dyn Future<Output = Result<bool>> + Send>, +) -> Result<( + Multiaddr, + PeerId, + BidQuote, + bitcoin::Amount, + bitcoin::Amount, +)> where - TB: Future<Output = Result<bitcoin::Amount>>, - FB: Fn() -> TB, - TMG: Future<Output = Result<(bitcoin::Amount, bitcoin::Amount)>>, - FMG: Fn() -> TMG, - TS: Future<Output = Result<()>>, - FS: Fn() -> TS, + TB: Future<Output = Result<bitcoin::Amount>> + Send + 'static, + FB: Fn() -> TB + Send + 'static, + TMG: Future<Output = Result<(bitcoin::Amount, bitcoin::Amount)>> + Send + 'static, + FMG: Fn() -> TMG + Send + 'static, + TS: Future<Output = Result<()>> + Send + 'static, + FS: Fn() -> TS + Send + 'static, + FQ: Fn() -> std::pin::Pin< + Box< + dyn Future< + Output = Result<( + tokio::task::JoinHandle<()>, + ::tokio::sync::watch::Receiver<Vec<SellerStatus>>, + )>, + > + Send, + >, + >, { - if bid_quote.max_quantity == bitcoin::Amount::ZERO { - bail!(ZeroQuoteReceived) - } - - tracing::info!( - price = %bid_quote.price, - minimum_amount = %bid_quote.min_quantity, - maximum_amount = %bid_quote.max_quantity, - "Received quote", - ); - - sync().await.context("Failed to sync of Bitcoin wallet")?; - let (mut max_giveable, mut spending_fee) = max_giveable_fn().await?; + // Start background tasks with watch channels + let (quote_fetch_handle, mut quotes_rx): ( + _, + ::tokio::sync::watch::Receiver<Vec<SellerStatus>>, + ) = quote_fetch_tasks().await?; + let (wallet_refresh_handle, mut balance_rx): ( + _, + ::tokio::sync::watch::Receiver<(bitcoin::Amount, bitcoin::Amount)>, + ) = refresh_wallet_task(max_giveable_fn, balance, sync).await?; + + // Get the abort handles to kill the background tasks when we exit the function + let quote_fetch_abort_handle = AbortOnDropHandle::new(quote_fetch_handle); + let wallet_refresh_abort_handle = AbortOnDropHandle::new(wallet_refresh_handle); + + let mut pending_approvals = FuturesUnordered::new(); + + let deposit_address = get_new_address.await?; + + loop { + // Get the latest quotes, balance and max_giveable + let quotes = quotes_rx.borrow().clone(); + let (balance, max_giveable) = *balance_rx.borrow(); + + let success_quotes = quotes + .iter() + .filter_map(|quote| match quote { + SellerStatus::Online(quote_with_address) => Some(quote_with_address.clone()), + SellerStatus::Unreachable(_) => None, + }) + .collect::<Vec<_>>(); + + // Emit a Tauri event + event_emitter.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::WaitingForBtcDeposit { + deposit_address: deposit_address.clone(), + max_giveable: max_giveable, + min_bitcoin_lock_tx_fee: balance - max_giveable, + known_quotes: success_quotes.clone(), + }, + ); - if max_giveable == bitcoin::Amount::ZERO || max_giveable < bid_quote.min_quantity { - let deposit_address = get_new_address.await?; - let minimum_amount = bid_quote.min_quantity; - let maximum_amount = bid_quote.max_quantity; + // Iterate through quotes and find ones that match the balance and max_giveable + let matching_quotes = success_quotes + .iter() + .filter_map(|quote_with_address| { + let quote = quote_with_address.quote; - // To avoid any issus, we clip maximum_amount to never go above the - // total maximim Bitcoin supply - let maximum_amount = maximum_amount.min(bitcoin::Amount::MAX_MONEY); + if quote.min_quantity <= max_giveable && quote.max_quantity > bitcoin::Amount::ZERO + { + let tx_lock_fee = balance - max_giveable; + let tx_lock_amount = std::cmp::min(max_giveable, quote.max_quantity); - if !json { - eprintln!("{}", qr_code(&deposit_address)?); + Some((quote_with_address.clone(), tx_lock_amount, tx_lock_fee)) + } else { + None + } + }) + .collect::<Vec<_>>(); + + // Put approval requests into FuturesUnordered + for (quote, tx_lock_amount, tx_lock_fee) in matching_quotes { + let future = request_approval(quote.clone()); + + pending_approvals.push(async move { + use std::pin::Pin; + let pinned_future = Pin::from(future); + let approved = pinned_future.await?; + + if approved { + Ok::< + Option<( + Multiaddr, + PeerId, + BidQuote, + bitcoin::Amount, + bitcoin::Amount, + )>, + anyhow::Error, + >(Some(( + quote.multiaddr.clone(), + quote.peer_id.clone(), + quote.quote.clone(), + tx_lock_amount, + tx_lock_fee, + ))) + } else { + Ok::< + Option<( + Multiaddr, + PeerId, + BidQuote, + bitcoin::Amount, + bitcoin::Amount, + )>, + anyhow::Error, + >(None) + } + }); } - loop { - let min_outstanding = bid_quote.min_quantity - max_giveable; - let min_bitcoin_lock_tx_fee = spending_fee; - let min_deposit_until_swap_will_start = min_outstanding + min_bitcoin_lock_tx_fee; - let max_deposit_until_maximum_amount_is_reached = maximum_amount - .checked_sub(max_giveable) - .context("Overflow when subtracting max_giveable from maximum_amount")? - .checked_add(min_bitcoin_lock_tx_fee) - .context(format!("Overflow when adding min_bitcoin_lock_tx_fee ({min_bitcoin_lock_tx_fee}) to max_giveable ({max_giveable}) with maximum_amount ({maximum_amount})"))?; - - tracing::info!( - "Deposit at least {} to cover the min quantity with fee!", - min_deposit_until_swap_will_start - ); - tracing::info!( - %deposit_address, - %min_deposit_until_swap_will_start, - %max_deposit_until_maximum_amount_is_reached, - %max_giveable, - %minimum_amount, - %maximum_amount, - %min_bitcoin_lock_tx_fee, - price = %bid_quote.price, - "Waiting for Bitcoin deposit", - ); - - if let Some(swap_id) = swap_id { - event_emitter.emit_swap_progress_event( - swap_id, - TauriSwapProgressEvent::WaitingForBtcDeposit { - deposit_address: deposit_address.clone(), - max_giveable, - min_deposit_until_swap_will_start, - max_deposit_until_maximum_amount_is_reached, - min_bitcoin_lock_tx_fee, - quote: bid_quote, - }, - ); - } - - (max_giveable, spending_fee) = loop { - sync() - .await - .context("Failed to sync Bitcoin wallet while waiting for deposit")?; - let (new_max_givable, new_fee) = max_giveable_fn().await?; + tracing::info!( + swap_id = ?swap_id, + pending_approvals = ?pending_approvals.len(), + balance = ?balance, + max_giveable = ?max_giveable, + quotes = ?quotes, + "Waiting for user to select an offer" + ); - if new_max_givable > max_giveable { - break (new_max_givable, new_fee); + // 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 } - - tokio::time::sleep(Duration::from_secs(1)).await; - }; - - let new_balance = balance().await?; - tracing::info!(%new_balance, %max_giveable, "Received Bitcoin"); - - if max_giveable < bid_quote.min_quantity { - tracing::info!("Deposited amount is not enough to cover `min_quantity` when accounting for network fees"); - continue; } + // 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 + } + }; - break; - } - }; + // 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(); - let balance = balance().await?; - let fees = balance - max_giveable; - let max_accepted = bid_quote.max_quantity; - let btc_swap_amount = min(max_giveable, max_accepted); + return Ok((multiaddr, peer_id, quote, tx_lock_amount, tx_lock_fee)); + } - Ok((btc_swap_amount, fees)) + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } } #[typeshare] diff --git a/swap/src/cli/api/tauri_bindings.rs b/swap/src/cli/api/tauri_bindings.rs index d51ea172ed..a489a711e3 100644 --- a/swap/src/cli/api/tauri_bindings.rs +++ b/swap/src/cli/api/tauri_bindings.rs @@ -1,5 +1,6 @@ use super::request::BalanceResponse; use crate::bitcoin; +use crate::cli::list_sellers::QuoteWithAddress; use crate::monero::MoneroAddressPool; use crate::{bitcoin::ExpiredTimelocks, monero, network::quote::BidQuote}; use anyhow::{anyhow, bail, Context, Result}; @@ -9,12 +10,11 @@ use monero_rpc_pool::pool::PoolStatus; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt::Display; -use std::future::Future; -use std::pin::Pin; use std::sync::Arc; +use std::sync::Mutex; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use strum::Display; -use tokio::sync::{oneshot, Mutex as TokioMutex}; +use tokio::sync::oneshot; use typeshare::typeshare; use uuid::Uuid; @@ -51,6 +51,17 @@ pub struct LockBitcoinDetails { pub swap_id: Uuid, } +#[typeshare] +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SelectMakerDetails { + #[typeshare(serialized_as = "string")] + pub swap_id: Uuid, + #[typeshare(serialized_as = "number")] + #[serde(with = "::bitcoin::amount::serde::as_sat")] + pub btc_amount_to_swap: bitcoin::Amount, + pub maker: QuoteWithAddress, +} + #[typeshare] #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "type", content = "content")] @@ -75,6 +86,9 @@ pub enum ApprovalRequestType { /// Request approval before locking Bitcoin. /// Contains specific details for review. LockBitcoin(LockBitcoinDetails), + /// Request approval for maker selection. + /// Contains available makers and swap details. + SelectMaker(SelectMakerDetails), /// Request seed selection from user. /// User can choose between random seed or provide their own. SeedSelection, @@ -101,6 +115,15 @@ struct PendingApproval { expiration_ts: u64, } +impl Drop for PendingApproval { + fn drop(&mut self) { + if let Some(responder) = self.responder.take() { + tracing::debug!("Dropping pending approval because handle was dropped"); + let _ = responder.send(serde_json::Value::Bool(false)); + } + } +} + #[typeshare] #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TorBootstrapStatus { @@ -112,7 +135,7 @@ pub struct TorBootstrapStatus { #[cfg(feature = "tauri")] struct TauriHandleInner { app_handle: tauri::AppHandle, - pending_approvals: TokioMutex<HashMap<Uuid, PendingApproval>>, + pending_approvals: Arc<Mutex<HashMap<Uuid, PendingApproval>>>, } #[derive(Clone)] @@ -131,7 +154,7 @@ impl TauriHandle { #[cfg(feature = "tauri")] Arc::new(TauriHandleInner { app_handle: tauri_handle, - pending_approvals: TokioMutex::new(HashMap::new()), + pending_approvals: Arc::new(Mutex::new(HashMap::new())), }), ) } @@ -149,6 +172,7 @@ impl TauriHandle { /// Helper to emit a approval event via the unified event name fn emit_approval(&self, event: ApprovalRequest) { + tracing::debug!(?event, "Emitting approval event"); self.emit_unified_event(TauriEvent::Approval(event)) } @@ -175,7 +199,7 @@ impl TauriHandle { let timeout_secs = timeout_secs.unwrap_or(60 * 60 * 24 * 7); let expiration_ts = SystemTime::now() .duration_since(UNIX_EPOCH) - .unwrap() + .map_err(|e| anyhow!("Failed to get current time: {}", e))? .as_secs() + timeout_secs; let request = ApprovalRequest { @@ -188,7 +212,6 @@ impl TauriHandle { self.emit_approval(request.clone()); tracing::debug!(%request, "Emitted approval request event"); - // Construct the data structure we use to internally track the approval request let (responder, receiver) = oneshot::channel(); @@ -198,17 +221,28 @@ impl TauriHandle { responder: Some(responder), expiration_ts: SystemTime::now() .duration_since(UNIX_EPOCH) - .unwrap() + .map_err(|e| anyhow!("Failed to get current time: {}", e))? .as_secs() + timeout_secs, }; // Lock map and insert the pending approval { - let mut pending_map = self.0.pending_approvals.lock().await; - pending_map.insert(request.request_id, pending); + let mut pending_map = self + .0 + .pending_approvals + .lock() + .map_err(|e| anyhow!("Failed to acquire approval lock: {}", e))?; + pending_map.insert(request_id, pending); } + // Create cleanup guard to handle cancellation + let mut cleanup_guard = ApprovalCleanupGuard::new( + request_id, + self.clone(), + self.0.pending_approvals.clone(), + ); + // Determine if the request will be accepted or rejected // Either by being resolved by the user, or by timing out let unparsed_response = tokio::select! { @@ -223,14 +257,18 @@ impl TauriHandle { let response: Result<Response> = serde_json::from_value(unparsed_response.clone()) .context("Failed to parse approval response to expected type"); - let mut map = self.0.pending_approvals.lock().await; - if let Some(_pending) = map.remove(&request.request_id) { + let mut map = self + .0 + .pending_approvals + .lock() + .map_err(|e| anyhow!("Failed to acquire approval lock: {}", e))?; + if let Some(_pending) = map.remove(&request_id) { let status = if response.is_ok() { RequestStatus::Resolved { approve_input: unparsed_response, } } else { - RequestStatus::Rejected {} + RequestStatus::Rejected }; let mut approval = request.clone(); @@ -260,15 +298,19 @@ impl TauriHandle { #[cfg(feature = "tauri")] { - let mut pending_map = self.0.pending_approvals.lock().await; - if let Some(pending) = pending_map.get_mut(&request_id) { - let _ = pending - .responder - .take() - .context("Approval responder was already consumed")? - .send(response); - - Ok(()) + let mut pending_map = self + .0 + .pending_approvals + .lock() + .map_err(|e| anyhow!("Failed to acquire approval lock: {}", e))?; + if let Some(mut pending) = pending_map.remove(&request_id) { + // Send response through oneshot channel + if let Some(responder) = pending.responder.take() { + let _ = responder.send(response); + Ok(()) + } else { + Err(anyhow!("Approval responder was already consumed")) + } } else { Err(anyhow!("Approval not found or already handled")) } @@ -280,6 +322,7 @@ impl Display for ApprovalRequest { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self.request { ApprovalRequestType::LockBitcoin(..) => write!(f, "LockBitcoin()"), + ApprovalRequestType::SelectMaker(..) => write!(f, "SelectMaker()"), ApprovalRequestType::SeedSelection => write!(f, "SeedSelection()"), } } @@ -293,6 +336,12 @@ pub trait TauriEmitter { timeout_secs: u64, ) -> Result<bool>; + async fn request_maker_selection( + &self, + details: SelectMakerDetails, + timeout_secs: u64, + ) -> Result<bool>; + async fn request_seed_selection(&self) -> Result<SeedChoice>; fn emit_tauri_event<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()>; @@ -375,6 +424,20 @@ impl TauriEmitter for TauriHandle { .unwrap_or(false)) } + async fn request_maker_selection( + &self, + details: SelectMakerDetails, + timeout_secs: u64, + ) -> Result<bool> { + Ok(self + .request_approval( + ApprovalRequestType::SelectMaker(details), + Some(timeout_secs), + ) + .await + .unwrap_or(false)) + } + async fn request_seed_selection(&self) -> Result<SeedChoice> { self.request_approval(ApprovalRequestType::SeedSelection, None) .await @@ -431,6 +494,17 @@ impl TauriEmitter for Option<TauriHandle> { } } + async fn request_maker_selection( + &self, + details: SelectMakerDetails, + timeout_secs: u64, + ) -> Result<bool> { + match self { + Some(tauri) => tauri.request_maker_selection(details, timeout_secs).await, + None => bail!("No Tauri handle available"), + } + } + async fn request_seed_selection(&self) -> Result<SeedChoice> { match self { Some(tauri) => tauri.request_seed_selection().await, @@ -648,14 +722,8 @@ pub enum TauriSwapProgressEvent { max_giveable: bitcoin::Amount, #[typeshare(serialized_as = "number")] #[serde(with = "::bitcoin::amount::serde::as_sat")] - min_deposit_until_swap_will_start: bitcoin::Amount, - #[typeshare(serialized_as = "number")] - #[serde(with = "::bitcoin::amount::serde::as_sat")] - max_deposit_until_maximum_amount_is_reached: bitcoin::Amount, - #[typeshare(serialized_as = "number")] - #[serde(with = "::bitcoin::amount::serde::as_sat")] min_bitcoin_lock_tx_fee: bitcoin::Amount, - quote: BidQuote, + known_quotes: Vec<QuoteWithAddress>, }, SwapSetupInflight { #[typeshare(serialized_as = "number")] @@ -795,3 +863,48 @@ pub struct ListSellersProgress { pub quotes_received: u32, pub quotes_failed: u32, } + +// Add this struct before the TauriHandle implementation +struct ApprovalCleanupGuard { + request_id: Option<Uuid>, + approval_store: Arc<Mutex<HashMap<Uuid, PendingApproval>>>, + handle: TauriHandle, +} + +impl ApprovalCleanupGuard { + fn new( + request_id: Uuid, + handle: TauriHandle, + approval_store: Arc<Mutex<HashMap<Uuid, PendingApproval>>>, + ) -> Self { + Self { + request_id: Some(request_id), + handle, + approval_store, + } + } + + /// Disarm the guard so it won't cleanup on drop (call when normally resolved) + fn disarm(&mut self) { + self.request_id = None; + } +} + +impl Drop for ApprovalCleanupGuard { + fn drop(&mut self) { + if let Some(request_id) = self.request_id { + tracing::debug!(%request_id, "Approval handle dropped, we should cleanup now"); + + // Lock the Mutex + if let Ok(mut approval_store) = self.approval_store.lock() { + // Check if the request id still present in the map + if let Some(mut pending_approval) = approval_store.remove(&request_id) { + // If there is still someone listening, send a rejection + if let Some(responder) = pending_approval.responder.take() { + let _ = responder.send(serde_json::Value::Bool(false)); + } + } + } + } + } +} diff --git a/swap/src/cli/command.rs b/swap/src/cli/command.rs index 2313fac6db..0b115546a3 100644 --- a/swap/src/cli/command.rs +++ b/swap/src/cli/command.rs @@ -89,7 +89,8 @@ where ); BuyXmrArgs { - seller, + rendezvous_points: vec![], + sellers: vec![seller], bitcoin_change_address, monero_receive_pool, } diff --git a/swap/src/cli/list_sellers.rs b/swap/src/cli/list_sellers.rs index 36f527db40..8bc539f96d 100644 --- a/swap/src/cli/list_sellers.rs +++ b/swap/src/cli/list_sellers.rs @@ -2,6 +2,7 @@ use crate::cli::api::tauri_bindings::{ ListSellersProgress, TauriBackgroundProgress, TauriBackgroundProgressHandle, TauriEmitter, TauriHandle, }; +use crate::libp2p_ext::MultiAddrExt; use crate::network::quote::BidQuote; use crate::network::rendezvous::XmrBtcNamespace; use crate::network::{quote, swarm}; @@ -16,7 +17,7 @@ use libp2p::swarm::dial_opts::DialOpts; use libp2p::swarm::{NetworkBehaviour, SwarmEvent}; use libp2p::{identity, ping, rendezvous, Multiaddr, PeerId, Swarm}; use semver::Version; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DisplayFromStr}; use std::collections::{HashMap, VecDeque}; use std::sync::Arc; @@ -32,6 +33,102 @@ fn build_identify_config(identity: identity::Keypair) -> identify::Config { identify::Config::new(protocol_version, identity.public()).with_agent_version(agent_version) } +/// Returns a function that when called will return sorted list of sellers, with [Online](Status::Online) listed first. +/// +/// First uses the rendezvous node to discover peers in the given namespace, +/// then fetches a quote from each peer that was discovered. If fetching a quote +/// from a discovered peer fails the seller's status will be +/// [Unreachable](Status::Unreachable). +/// +/// If a database is provided, it will be used to get the list of peers that +/// have already been discovered previously and attempt to fetch a quote from them. +pub async fn list_sellers_init( + rendezvous_points: Vec<(PeerId, Multiaddr)>, + namespace: XmrBtcNamespace, + maybe_tor_client: Option<Arc<TorClient<TokioRustlsRuntime>>>, + identity: identity::Keypair, + db: Option<Arc<dyn Database + Send + Sync>>, + tauri_handle: Option<TauriHandle>, + sender: Option<::tokio::sync::watch::Sender<Vec<SellerStatus>>>, + sellers: Vec<Multiaddr>, +) -> Result< + impl Fn() -> std::pin::Pin< + Box<dyn std::future::Future<Output = Vec<SellerStatus>> + Send + 'static>, + > + Send + + Sync + + 'static, +> { + // Capture variables needed to build an EventLoop on each invocation + let rendezvous_points_clone = rendezvous_points.clone(); + let namespace_clone = namespace; + let maybe_tor_client_clone = maybe_tor_client.clone(); + let identity_clone = identity.clone(); + let db_clone = db.clone(); + let tauri_handle_clone = tauri_handle.clone(); + let sellers_clone = sellers.clone(); + + Ok(move || { + // Clone captured values inside the closure to avoid moving them and thus implement `Fn` + let rendezvous_points = rendezvous_points_clone.clone(); + let namespace = namespace_clone; + let maybe_tor_client = maybe_tor_client_clone.clone(); + let identity = identity_clone.clone(); + let db = db_clone.clone(); + let tauri_handle = tauri_handle_clone.clone(); + let sender = sender.clone(); + let sellers = sellers_clone.clone(); + + Box::pin(async move { + // Build a fresh swarm and event loop for every call so the closure can be invoked multiple times. + let behaviour = Behaviour { + rendezvous: rendezvous::client::Behaviour::new(identity.clone()), + quote: quote::cli(), + ping: ping::Behaviour::new( + ping::Config::new().with_timeout(Duration::from_secs(60)), + ), + identify: identify::Behaviour::new(build_identify_config(identity.clone())), + }; + + // TODO: Dont use unwrap + let swarm = swarm::cli(identity, maybe_tor_client, behaviour) + .await + .unwrap(); + + // Get peers from the database, add them to the dial queue + let mut external_dial_queue = match db { + Some(db) => match db.get_all_peer_addresses().await { + Ok(peers) => VecDeque::from(peers), + Err(err) => { + tracing::error!(%err, "Failed to get peers from database for list_sellers"); + VecDeque::new() + } + }, + None => VecDeque::new(), + }; + + // Get peers the user has manually passed in, add them to the dial queue + for seller_addr in sellers { + if let Some((peer_id, multiaddr)) = seller_addr.split_peer_id() { + external_dial_queue.push_back((peer_id, vec![multiaddr])); + } + } + + let event_loop = EventLoop::new( + swarm, + rendezvous_points, + namespace, + external_dial_queue, + tauri_handle, + ); + + event_loop.run(sender).await + }) + as std::pin::Pin< + Box<dyn std::future::Future<Output = Vec<SellerStatus>> + Send + 'static>, + > + }) +} + /// Returns sorted list of sellers, with [Online](Status::Online) listed first. /// /// First uses the rendezvous node to discover peers in the given namespace, @@ -49,38 +146,23 @@ pub async fn list_sellers( db: Option<Arc<dyn Database + Send + Sync>>, tauri_handle: Option<TauriHandle>, ) -> Result<Vec<SellerStatus>> { - let behaviour = Behaviour { - rendezvous: rendezvous::client::Behaviour::new(identity.clone()), - quote: quote::cli(), - ping: ping::Behaviour::new(ping::Config::new().with_timeout(Duration::from_secs(60))), - identify: identify::Behaviour::new(build_identify_config(identity.clone())), - }; - let swarm = swarm::cli(identity, maybe_tor_client, behaviour).await?; - - // If a database is passed in: Fetch all peer addresses from the database and fetch quotes from them - let external_dial_queue = match db { - Some(db) => { - let peers = db.get_all_peer_addresses().await?; - VecDeque::from(peers) - } - None => VecDeque::new(), - }; - - let event_loop = EventLoop::new( - swarm, + let fetch_fn = list_sellers_init( rendezvous_points, namespace, - external_dial_queue, + maybe_tor_client, + identity, + db, tauri_handle, - ); - let sellers = event_loop.run().await; - - Ok(sellers) + None, + Vec::new(), + ) + .await?; + Ok(fetch_fn().await) } #[serde_as] #[typeshare] -#[derive(Debug, Serialize, PartialEq, Eq, Hash, Clone, Ord, PartialOrd)] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Ord, PartialOrd)] pub struct QuoteWithAddress { /// The multiaddr of the seller (at which we were able to connect to and get the quote from) #[serde_as(as = "DisplayFromStr")] @@ -578,7 +660,49 @@ impl EventLoop { } } - async fn run(mut self) -> Vec<SellerStatus> { + fn build_current_sellers(&self) -> Vec<SellerStatus> { + let mut sellers: Vec<SellerStatus> = self + .peer_states + .values() + .filter_map(|peer_state| match peer_state { + PeerState::Complete { + peer_id, + version, + quote, + reachable_addresses, + } => Some(SellerStatus::Online(QuoteWithAddress { + peer_id: *peer_id, + multiaddr: reachable_addresses[0].clone(), + quote: *quote, + version: version.clone(), + })), + PeerState::Failed { peer_id, .. } => { + Some(SellerStatus::Unreachable(UnreachableSeller { + peer_id: *peer_id, + })) + } + _ => None, // Skip pending states for partial updates + }) + .collect(); + + sellers.sort(); + sellers + } + + fn emit_partial_update( + &self, + sender: &Option<::tokio::sync::watch::Sender<Vec<SellerStatus>>>, + ) { + if let Some(sender) = sender { + let current_sellers = self.build_current_sellers(); + let _ = sender.send(current_sellers); + } + } + + async fn run( + mut self, + sender: Option<::tokio::sync::watch::Sender<Vec<SellerStatus>>>, + ) -> Vec<SellerStatus> { // Dial all rendezvous points initially for (peer_id, multiaddr) in &self.rendezvous_points { let dial_opts = DialOpts::peer_id(*peer_id) @@ -787,6 +911,8 @@ impl EventLoop { // If we have pending request to rendezvous points or quote requests, we continue if !all_rendezvous_points_requests_complete || !all_quotes_fetched { + // Emit partial update with any completed quotes we have so far + self.emit_partial_update(&sender); continue; }