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