diff --git a/src-gui/package.json b/src-gui/package.json index c8da3570e..34a4ea5df 100644 --- a/src-gui/package.json +++ b/src-gui/package.json @@ -18,7 +18,7 @@ "@material-ui/core": "^4.12.4", "@material-ui/icons": "^4.11.3", "@material-ui/lab": "^4.0.0-alpha.61", - "@reduxjs/toolkit": "^2.2.6", + "@reduxjs/toolkit": "^2.3.0", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-cli": "^2.0.0", "@tauri-apps/plugin-clipboard-manager": "^2.0.0", diff --git a/src-gui/src/models/tauriModelExt.ts b/src-gui/src/models/tauriModelExt.ts index de5a7caac..d64813a31 100644 --- a/src-gui/src/models/tauriModelExt.ts +++ b/src-gui/src/models/tauriModelExt.ts @@ -1,3 +1,4 @@ +import { exhaustiveGuard } from "utils/typescriptUtils"; import { ExpiredTimelocks, GetSwapInfoResponse, @@ -30,6 +31,26 @@ export enum BobStateName { SafelyAborted = "safely aborted", } +export function bobStateNameToHumanReadable(stateName: BobStateName): string { + switch (stateName) { + case BobStateName.Started: return "Started"; + case BobStateName.SwapSetupCompleted: return "Setup completed"; + case BobStateName.BtcLocked: return "Bitcoin locked"; + case BobStateName.XmrLockProofReceived: return "Monero locked"; + case BobStateName.XmrLocked: return "Monero locked and fully confirmed"; + case BobStateName.EncSigSent: return "Encrypted signature sent"; + case BobStateName.BtcRedeemed: return "Bitcoin redeemed"; + case BobStateName.CancelTimelockExpired: return "Cancel timelock expired"; + case BobStateName.BtcCancelled: return "Bitcoin cancelled"; + case BobStateName.BtcRefunded: return "Bitcoin refunded"; + case BobStateName.XmrRedeemed: return "Monero redeemed"; + case BobStateName.BtcPunished: return "Bitcoin punished"; + case BobStateName.SafelyAborted: return "Safely aborted"; + default: + return exhaustiveGuard(stateName); + } +} + // TODO: This is a temporary solution until we have a typeshare definition for BobStateName export type GetSwapInfoResponseExt = GetSwapInfoResponse & { state_name: BobStateName; @@ -39,6 +60,22 @@ export type TimelockNone = Extract; export type TimelockCancel = Extract; export type TimelockPunish = Extract; +// This function returns the absolute block number of the timelock relative to the block the tx_lock was included in +export function getAbsoluteBlock(timelock: ExpiredTimelocks, cancelTimelock: number, punishTimelock: number): number { + if (timelock.type === "None") { + return cancelTimelock - timelock.content.blocks_left; + } + if (timelock.type === "Cancel") { + return cancelTimelock + punishTimelock - timelock.content.blocks_left; + } + if (timelock.type === "Punish") { + return cancelTimelock + punishTimelock; + } + + // We match all cases + return exhaustiveGuard(timelock); +} + export type BobStateNameRunningSwap = Exclude< BobStateName, | BobStateName.Started @@ -50,7 +87,11 @@ export type BobStateNameRunningSwap = Exclude< >; export type GetSwapInfoResponseExtRunningSwap = GetSwapInfoResponseExt & { - stateName: BobStateNameRunningSwap; + state_name: BobStateNameRunningSwap; +}; + +export type GetSwapInfoResponseExtWithTimelock = GetSwapInfoResponseExt & { + timelock: ExpiredTimelocks; }; export function isBobStateNameRunningSwap( @@ -157,3 +198,14 @@ export function isGetSwapInfoResponseRunningSwap( ): response is GetSwapInfoResponseExtRunningSwap { return isBobStateNameRunningSwap(response.state_name); } + +/** + * Type guard for GetSwapInfoResponseExt to ensure timelock is not null + * @param response The swap info response to check + * @returns True if the timelock exists, false otherwise + */ +export function isGetSwapInfoResponseWithTimelock( + response: GetSwapInfoResponseExt +): response is GetSwapInfoResponseExtWithTimelock { + return response.timelock !== null; +} diff --git a/src-gui/src/renderer/components/alert/RemainingFundsWillBeUsedAlert.tsx b/src-gui/src/renderer/components/alert/RemainingFundsWillBeUsedAlert.tsx index fee72d430..33b505e72 100644 --- a/src-gui/src/renderer/components/alert/RemainingFundsWillBeUsedAlert.tsx +++ b/src-gui/src/renderer/components/alert/RemainingFundsWillBeUsedAlert.tsx @@ -5,31 +5,31 @@ import { SatsAmount } from "../other/Units"; import WalletRefreshButton from "../pages/wallet/WalletRefreshButton"; const useStyles = makeStyles((theme) => ({ - outer: { - paddingBottom: theme.spacing(1), - }, + outer: { + paddingBottom: theme.spacing(1), + }, })); export default function RemainingFundsWillBeUsedAlert() { - const classes = useStyles(); - const balance = useAppSelector((s) => s.rpc.state.balance); + const classes = useStyles(); + const balance = useAppSelector((s) => s.rpc.state.balance); - if (balance == null || balance <= 0) { - return <>; - } + if (balance == null || balance <= 0) { + return <>; + } - return ( - - } - variant="filled" - > - The remaining funds of in the wallet - will be used for the next swap. If the remaining funds exceed the - minimum swap amount of the provider, a swap will be initiated - instantaneously. - - - ); + return ( + + } + variant="filled" + > + The remaining funds of in the wallet + will be used for the next swap. If the remaining funds exceed the + minimum swap amount of the provider, a swap will be initiated + instantaneously. + + + ); } diff --git a/src-gui/src/renderer/components/alert/SwapMightBeCancelledAlert.tsx b/src-gui/src/renderer/components/alert/SwapMightBeCancelledAlert.tsx deleted file mode 100644 index 648dee3de..000000000 --- a/src-gui/src/renderer/components/alert/SwapMightBeCancelledAlert.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { makeStyles } from "@material-ui/core"; -import { Alert, AlertTitle } from "@material-ui/lab"; -import { - isSwapTimelockInfoCancelled, - isSwapTimelockInfoNone, -} from "models/rpcModel"; -import { useActiveSwapInfo } from "store/hooks"; -import HumanizedBitcoinBlockDuration from "../other/HumanizedBitcoinBlockDuration"; - -const useStyles = makeStyles((theme) => ({ - outer: { - marginBottom: theme.spacing(1), - }, - list: { - margin: theme.spacing(0.25), - }, -})); - -export default function SwapMightBeCancelledAlert({ - bobBtcLockTxConfirmations, -}: { - bobBtcLockTxConfirmations: number; -}) { - // TODO: Reimplement this using Tauri - return <>; - - const classes = useStyles(); - const swap = useActiveSwapInfo(); - - if ( - bobBtcLockTxConfirmations < 5 || - swap === null || - swap.timelock === null - ) { - return <>; - } - - const { timelock } = swap; - const punishTimelockOffset = swap.punish_timelock; - - return ( - - Be careful! - The swap provider has taken a long time to lock their Monero. This might - mean that: -
    -
  • - There is a technical issue that prevents them from locking their funds -
  • -
  • They are a malicious actor (unlikely)
  • -
-
- There is still hope for the swap to be successful but you have to be extra - careful. Regardless of why it has taken them so long, it is important that - you refund the swap within the required time period if the swap is not - completed. If you fail to to do so, you will be punished and lose your - money. -
    - {isSwapTimelockInfoNone(timelock) && ( - <> -
  • - - You will be able to refund in about{" "} - - -
  • - -
  • - - If you have not refunded or completed the swap in about{" "} - - , you will lose your funds. - -
  • - - )} - {isSwapTimelockInfoCancelled(timelock) && ( -
  • - - If you have not refunded or completed the swap in about{" "} - - , you will lose your funds. - -
  • - )} -
  • - As long as you see this screen, the swap will be refunded - automatically when the time comes. If this fails, you have to manually - refund by navigating to the History page. -
  • -
-
- ); -} diff --git a/src-gui/src/renderer/components/alert/SwapStatusAlert.tsx b/src-gui/src/renderer/components/alert/SwapStatusAlert/SwapStatusAlert.tsx similarity index 52% rename from src-gui/src/renderer/components/alert/SwapStatusAlert.tsx rename to src-gui/src/renderer/components/alert/SwapStatusAlert/SwapStatusAlert.tsx index d933003c2..f3b033cb2 100644 --- a/src-gui/src/renderer/components/alert/SwapStatusAlert.tsx +++ b/src-gui/src/renderer/components/alert/SwapStatusAlert/SwapStatusAlert.tsx @@ -1,72 +1,79 @@ import { Box, makeStyles } from "@material-ui/core"; import { Alert, AlertTitle } from "@material-ui/lab/"; -import { GetSwapInfoResponse } from "models/tauriModel"; import { BobStateName, GetSwapInfoResponseExt, + GetSwapInfoResponseExtRunningSwap, isGetSwapInfoResponseRunningSwap, + isGetSwapInfoResponseWithTimelock, TimelockCancel, TimelockNone, } from "models/tauriModelExt"; import { ReactNode } from "react"; import { exhaustiveGuard } from "utils/typescriptUtils"; -import HumanizedBitcoinBlockDuration from "../other/HumanizedBitcoinBlockDuration"; -import TruncatedText from "../other/TruncatedText"; -import { - SwapCancelRefundButton, - SwapResumeButton, -} from "../pages/history/table/HistoryRowActions"; -import { SwapMoneroRecoveryButton } from "../pages/history/table/SwapMoneroRecoveryButton"; +import HumanizedBitcoinBlockDuration from "../../other/HumanizedBitcoinBlockDuration"; +import TruncatedText from "../../other/TruncatedText"; +import { SwapMoneroRecoveryButton } from "../../pages/history/table/SwapMoneroRecoveryButton"; +import { TimelockTimeline } from "./TimelockTimeline"; -const useStyles = makeStyles({ +const useStyles = makeStyles((theme) => ({ box: { display: "flex", flexDirection: "column", - gap: "0.5rem", + gap: theme.spacing(1), }, list: { padding: "0px", margin: "0px", + "& li": { + marginBottom: theme.spacing(0.5), + "&:last-child": { + marginBottom: 0 + } + }, }, -}); + alertMessage: { + flexGrow: 1, + }, +})); /** * Component for displaying a list of messages. * @param messages - Array of messages to display. * @returns JSX.Element */ -const MessageList = ({ messages }: { messages: ReactNode[] }) => { +function MessageList({ messages }: { messages: ReactNode[]; }) { const classes = useStyles(); + return (
    - {messages.map((msg, i) => ( + {messages.filter(msg => msg != null).map((msg, i) => (
  • {msg}
  • ))}
); -}; +} /** * Sub-component for displaying alerts when the swap is in a safe state. * @param swap - The swap information. * @returns JSX.Element */ -const BitcoinRedeemedStateAlert = ({ swap }: { swap: GetSwapInfoResponse }) => { +function BitcoinRedeemedStateAlert({ swap }: { swap: GetSwapInfoResponseExt; }) { const classes = useStyles(); return ( + "There is no risk of losing funds. Take as much time as you need", + "The Monero will automatically be redeemed to your provided address once you resume the swap", + "If this step fails, you can manually redeem your funds", + ]} /> ); -}; +} /** * Sub-component for displaying alerts when the swap is in a state with no timelock info. @@ -74,30 +81,33 @@ const BitcoinRedeemedStateAlert = ({ swap }: { swap: GetSwapInfoResponse }) => { * @param punishTimelockOffset - The punish timelock offset. * @returns JSX.Element */ -const BitcoinLockedNoTimelockExpiredStateAlert = ({ - timelock, - punishTimelockOffset, +function BitcoinLockedNoTimelockExpiredStateAlert({ + timelock, cancelTimelockOffset, punishTimelockOffset, isRunning, }: { timelock: TimelockNone; + cancelTimelockOffset: number; punishTimelockOffset: number; -}) => ( - - Your Bitcoin is locked. If the swap is not completed in approximately{" "} - , - you need to refund - , - <> - You might lose your funds if you do not refund or complete the swap - within{" "} - - , - ]} - /> -); + isRunning: boolean; +}) { + return ( + + If the swap isn't completed in {" "} + , it needs to be refunded + , + "For that, you need to have the app open sometime within the refund period", + <> + After that, cooperation from the other party would be required to recover the funds + , + isRunning ? null : "Please resume the swap to continue" + ]} /> + ); +} /** * Sub-component for displaying alerts when the swap timelock is expired @@ -106,46 +116,49 @@ const BitcoinLockedNoTimelockExpiredStateAlert = ({ * @param swap - The swap information. * @returns JSX.Element */ -const BitcoinPossiblyCancelledAlert = ({ - swap, - timelock, +function BitcoinPossiblyCancelledAlert({ + swap, timelock, }: { swap: GetSwapInfoResponseExt; timelock: TimelockCancel; -}) => { - const classes = useStyles(); +}) { return ( - - - You might lose your funds if you do not refund within{" "} - - , - ]} - /> - + + If we haven't refunded in{" "} + + , cooperation from the other party will be required to recover the funds + + ]} /> ); -}; +} /** * Sub-component for displaying alerts requiring immediate action. * @returns JSX.Element */ -const ImmediateActionAlert = () => ( - <>Resume the swap immediately to avoid losing your funds -); +function PunishTimelockExpiredAlert() { + return ( + + ); +} /** * Main component for displaying the appropriate swap alert status text. * @param swap - The swap information. * @returns JSX.Element | null */ -function SwapAlertStatusText({ swap }: { swap: GetSwapInfoResponseExt }) { +export function StateAlert({ swap, isRunning }: { swap: GetSwapInfoResponseExtRunningSwap; isRunning: boolean; }) { + switch (swap.state_name) { // This is the state where the swap is safe because the other party has redeemed the Bitcoin // It cannot be punished anymore @@ -165,11 +178,12 @@ function SwapAlertStatusText({ swap }: { swap: GetSwapInfoResponseExt }) { case "None": return ( ); - case "Cancel": return ( ); case "Punish": - return ; - + return ; default: // We have covered all possible timelock states above // If we reach this point, it means we have missed a case exhaustiveGuard(swap.timelock); } } - return ; + return ; + default: - // TODO: fix the exhaustive guard - // return exhaustiveGuard(swap.state_name); - return <>; + exhaustiveGuard(swap.state_name); } } @@ -201,27 +213,37 @@ function SwapAlertStatusText({ swap }: { swap: GetSwapInfoResponseExt }) { */ export default function SwapStatusAlert({ swap, + isRunning, }: { swap: GetSwapInfoResponseExt; + isRunning: boolean; }): JSX.Element | null { - // If the swap is completed, there is no need to display the alert - // TODO: Here we should also check if the swap is in a state where any funds can be lost - // TODO: If the no Bitcoin have been locked yet, we can safely ignore the swap + const classes = useStyles(); + + // If the swap is completed, we do not need to display anything if (!isGetSwapInfoResponseRunningSwap(swap)) { return null; } + // If we don't have a timelock for the swap, we cannot display the alert + if (!isGetSwapInfoResponseWithTimelock(swap)) { + return null; + } + return ( Resume Swap} variant="filled" + classes={{ message: classes.alertMessage }} > - Swap {swap.swap_id} is unfinished + {isRunning ? "Swap has been running for a while" : <>Swap {swap.swap_id} is not running} - + + + + ); } diff --git a/src-gui/src/renderer/components/alert/SwapStatusAlert/TimelockTimeline.tsx b/src-gui/src/renderer/components/alert/SwapStatusAlert/TimelockTimeline.tsx new file mode 100644 index 000000000..43cf4c168 --- /dev/null +++ b/src-gui/src/renderer/components/alert/SwapStatusAlert/TimelockTimeline.tsx @@ -0,0 +1,176 @@ +import { useTheme, Tooltip, Typography, Box, LinearProgress, Paper } from "@material-ui/core"; +import { ExpiredTimelocks } from "models/tauriModel"; +import { GetSwapInfoResponseExt, getAbsoluteBlock } from "models/tauriModelExt"; +import HumanizedBitcoinBlockDuration from "renderer/components/other/HumanizedBitcoinBlockDuration"; + +interface TimelineSegment { + title: string; + label: string; + bgcolor: string; + startBlock: number; +} + +interface TimelineSegmentProps { + segment: TimelineSegment; + isActive: boolean; + absoluteBlock: number; + durationOfSegment: number | null; + totalBlocks: number; +} + +function TimelineSegment({ + segment, + isActive, + absoluteBlock, + durationOfSegment, + totalBlocks +}: TimelineSegmentProps) { + const theme = useTheme(); + + return ( + {segment.title}}> + + {isActive && ( + + + + )} + + {segment.label} + + {durationOfSegment && ( + + {isActive && ( + <> + {" "}left + + )} + {!isActive && ( + + )} + + )} + + + ); +} + +export function TimelockTimeline({ swap }: { + // This forces the timelock to not be null + swap: GetSwapInfoResponseExt & { timelock: ExpiredTimelocks } +}) { + const theme = useTheme(); + + const timelineSegments: TimelineSegment[] = [ + { + title: "Normally a swap is completed during this period", + label: "Normal", + bgcolor: theme.palette.success.main, + startBlock: 0, + }, + { + title: "If the swap hasn't been completed before we reach this period, the Bitcoin needs to be refunded. For that, you need to have the app open sometime within the refund period", + label: "Refund", + bgcolor: theme.palette.warning.main, + startBlock: swap.cancel_timelock, + }, + { + title: "If you didn't refund within the refund window, you will enter this period. At this point, the Bitcoin can no longer be refunded. It may still be possible to redeem the Monero with cooperation from the other party but this cannot be guaranteed.", + label: "Danger", + bgcolor: theme.palette.error.main, + startBlock: swap.cancel_timelock + swap.punish_timelock, + } + ]; + + const totalBlocks = swap.cancel_timelock + swap.punish_timelock; + const absoluteBlock = getAbsoluteBlock(swap.timelock, swap.cancel_timelock, swap.punish_timelock); + + // This calculates the duration of a segment + // by getting the the difference to the next segment + function durationOfSegment(index: number): number | null { + const nextSegment = timelineSegments[index + 1]; + if (nextSegment == null) { + return null; + } + return nextSegment.startBlock - timelineSegments[index].startBlock; + } + + // This function returns the index of the active segment based on the current block + // We iterate in reverse to find the first segment that has a start block less than the current block + function getActiveSegmentIndex() { + return Array.from(timelineSegments + .slice() + // We use .entries() to keep the indexes despite reversing + .entries()) + .reverse() + .find(([_, segment]) => absoluteBlock >= segment.startBlock)?.[0] ?? 0; + } + + return ( + + + + {timelineSegments.map((segment, index) => ( + + ))} + + + + ); +} \ No newline at end of file diff --git a/src-gui/src/renderer/components/alert/SwapTxLockAlertsBox.tsx b/src-gui/src/renderer/components/alert/SwapTxLockAlertsBox.tsx index a0bacfea2..9c87475be 100644 --- a/src-gui/src/renderer/components/alert/SwapTxLockAlertsBox.tsx +++ b/src-gui/src/renderer/components/alert/SwapTxLockAlertsBox.tsx @@ -1,6 +1,6 @@ import { Box, makeStyles } from "@material-ui/core"; import { useSwapInfosSortedByDate } from "store/hooks"; -import SwapStatusAlert from "./SwapStatusAlert"; +import SwapStatusAlert from "./SwapStatusAlert/SwapStatusAlert"; const useStyles = makeStyles((theme) => ({ outer: { @@ -21,7 +21,7 @@ export default function SwapTxLockAlertsBox() { return ( {swaps.map((swap) => ( - + ))} ); diff --git a/src-gui/src/renderer/components/modal/swap/pages/in_progress/BitcoinLockTxInMempoolPage.tsx b/src-gui/src/renderer/components/modal/swap/pages/in_progress/BitcoinLockTxInMempoolPage.tsx index 75ebf88ae..cccead19d 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/in_progress/BitcoinLockTxInMempoolPage.tsx +++ b/src-gui/src/renderer/components/modal/swap/pages/in_progress/BitcoinLockTxInMempoolPage.tsx @@ -1,35 +1,51 @@ -import { Box, DialogContentText } from "@material-ui/core"; import { TauriSwapProgressEventContent } from "models/tauriModelExt"; -import SwapMightBeCancelledAlert from "../../../../alert/SwapMightBeCancelledAlert"; import BitcoinTransactionInfoBox from "../../BitcoinTransactionInfoBox"; +import SwapStatusAlert from "renderer/components/alert/SwapStatusAlert/SwapStatusAlert"; +import { useActiveSwapInfo } from "store/hooks"; +import { Box, DialogContentText } from "@material-ui/core"; + +// This is the number of blocks after which we consider the swap to be at risk of being unsuccessful +const BITCOIN_CONFIRMATIONS_WARNING_THRESHOLD = 2; export default function BitcoinLockTxInMempoolPage({ btc_lock_confirmations, btc_lock_txid, }: TauriSwapProgressEventContent<"BtcLockTxInMempool">) { + const swapInfo = useActiveSwapInfo(); + return ( - - - The Bitcoin lock transaction has been published. The swap will proceed - once the transaction is confirmed and the swap provider locks their - Monero. - - - Most swap providers require one confirmation before locking their - Monero -
- Confirmations: {btc_lock_confirmations} - - } - /> + {btc_lock_confirmations < BITCOIN_CONFIRMATIONS_WARNING_THRESHOLD && ( + + Your Bitcoin has been locked. {btc_lock_confirmations > 0 ? + "We are waiting for the other party to lock their Monero." : + "We are waiting for the blockchain to confirm the transaction. Once confirmed, the other party will lock their Monero." + } + + )} + + {btc_lock_confirmations >= BITCOIN_CONFIRMATIONS_WARNING_THRESHOLD && ( + + )} + + Most swap providers require one confirmation before locking their + Monero. After they lock their funds and the Monero transaction + receives one confirmation, the swap will proceed to the next step. +
+ Confirmations: {btc_lock_confirmations} + + } + /> +
); } diff --git a/src-gui/src/renderer/components/modal/swap/pages/init/InitPage.tsx b/src-gui/src/renderer/components/modal/swap/pages/init/InitPage.tsx index 04243d7f9..ce1d2e04c 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/init/InitPage.tsx +++ b/src-gui/src/renderer/components/modal/swap/pages/init/InitPage.tsx @@ -8,12 +8,12 @@ import { } from "@material-ui/core"; import PlayArrowIcon from "@material-ui/icons/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 } from "store/hooks"; -import RemainingFundsWillBeUsedAlert from "../../../../alert/RemainingFundsWillBeUsedAlert"; const useStyles = makeStyles((theme) => ({ initButton: { diff --git a/src-gui/src/renderer/components/other/HumanizedBitcoinBlockDuration.tsx b/src-gui/src/renderer/components/other/HumanizedBitcoinBlockDuration.tsx index c0d226e9c..87fbb9443 100644 --- a/src-gui/src/renderer/components/other/HumanizedBitcoinBlockDuration.tsx +++ b/src-gui/src/renderer/components/other/HumanizedBitcoinBlockDuration.tsx @@ -4,14 +4,16 @@ const AVG_BLOCK_TIME_MS = 10 * 60 * 1000; export default function HumanizedBitcoinBlockDuration({ blocks, + displayBlocks = true, }: { blocks: number; + displayBlocks?: boolean; }) { return ( <> - {`${humanizeDuration(blocks * AVG_BLOCK_TIME_MS, { + {`≈ ${humanizeDuration(blocks * AVG_BLOCK_TIME_MS, { conjunction: " and ", - })} (${blocks} blocks)`} + })}${displayBlocks ? ` (${blocks} blocks)` : ""}`} ); } diff --git a/src-gui/src/renderer/components/pages/help/SettingsBox.tsx b/src-gui/src/renderer/components/pages/help/SettingsBox.tsx index a9727d1ad..77f846532 100644 --- a/src-gui/src/renderer/components/pages/help/SettingsBox.tsx +++ b/src-gui/src/renderer/components/pages/help/SettingsBox.tsx @@ -347,6 +347,37 @@ function NodeTableModal({ ) } +// Create a circle SVG with a given color and radius +function Circle({ color, radius = 6 }: { color: string, radius?: number }) { + return + + + + +} + +/** + * Displays a status indicator for a node + */ +function NodeStatus({ status }: { status: boolean | undefined }) { + const theme = useTheme(); + + switch (status) { + case true: + return + + ; + case false: + return + + ; + default: + return + + ; + } +} + /** * A table that displays the available nodes for a given network and blockchain. * It allows you to add, remove, and move nodes up the list. @@ -368,31 +399,6 @@ function NodeTable({ const nodeStatuses = useNodes((s) => s.nodes); const [newNode, setNewNode] = useState(""); const dispatch = useAppDispatch(); - const theme = useTheme(); - - // Create a circle SVG with a given color and radius - const circle = (color: string, radius: number = 6) => - - ; - - // Show a green/red circle or a hourglass icon depending on the status of the node - const statusIcon = (node: string) => { - switch (nodeStatuses[blockchain][node]) { - case true: - return - {circle(theme.palette.success.dark)} - ; - case false: - return - {circle(theme.palette.error.dark)} - ; - default: - console.log(`Unknown status for node ${node}: ${nodeStatuses[node]}`); - return - - ; - } - } const onAddNewNode = () => { dispatch(addNode({ network, type: blockchain, node: newNode })); @@ -438,7 +444,9 @@ function NodeTable({ {node} {/* Node status icon */} - + + + {/* Remove and move buttons */} diff --git a/src-gui/src/renderer/components/pages/history/table/HistoryRow.tsx b/src-gui/src/renderer/components/pages/history/table/HistoryRow.tsx index 4f1748ca2..5b7154e1f 100644 --- a/src-gui/src/renderer/components/pages/history/table/HistoryRow.tsx +++ b/src-gui/src/renderer/components/pages/history/table/HistoryRow.tsx @@ -15,6 +15,7 @@ import TruncatedText from "renderer/components/other/TruncatedText"; import { PiconeroAmount, SatsAmount } from "../../../other/Units"; import HistoryRowActions from "./HistoryRowActions"; import HistoryRowExpanded from "./HistoryRowExpanded"; +import { bobStateNameToHumanReadable, GetSwapInfoResponseExt } from "models/tauriModelExt"; const useStyles = makeStyles((theme) => ({ amountTransferContainer: { @@ -42,7 +43,7 @@ function AmountTransfer({ ); } -export default function HistoryRow(swap: GetSwapInfoResponse) { +export default function HistoryRow(swap: GetSwapInfoResponseExt) { const [expanded, setExpanded] = useState(false); return ( @@ -62,7 +63,7 @@ export default function HistoryRow(swap: GetSwapInfoResponse) { btcAmount={swap.btc_amount} /> - {swap.state_name.toString()} + {bobStateNameToHumanReadable(swap.state_name)} diff --git a/src-gui/src/renderer/components/pages/history/table/HistoryRowExpanded.tsx b/src-gui/src/renderer/components/pages/history/table/HistoryRowExpanded.tsx index 3c0746979..c3010bc46 100644 --- a/src-gui/src/renderer/components/pages/history/table/HistoryRowExpanded.tsx +++ b/src-gui/src/renderer/components/pages/history/table/HistoryRowExpanded.tsx @@ -27,6 +27,11 @@ const useStyles = makeStyles((theme) => ({ padding: theme.spacing(1), gap: theme.spacing(1), }, + outerAddressBox: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(1), + }, actionsOuter: { display: "flex", flexDirection: "row", @@ -88,7 +93,7 @@ export default function HistoryRowExpanded({ Provider Address - + {swap.seller.addresses.map((addr) => ( - {/* - // TOOD: reimplement these buttons using Tauri - - - - */} ); diff --git a/src-gui/src/renderer/rpc.ts b/src-gui/src/renderer/rpc.ts index 0558055be..1a22003da 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -44,8 +44,8 @@ import { MoneroRecoveryResponse } from "models/rpcModel"; import { ListSellersResponse } from "../models/tauriModel"; import logger from "utils/logger"; import { getNetwork, getNetworkName, isTestnet } from "store/config"; -import { Blockchain, Network } from "store/features/settingsSlice"; -import { resetStatuses, setPromise, setStatus, setStatuses } from "store/features/nodesSlice"; +import { Blockchain } from "store/features/settingsSlice"; +import { setStatus } from "store/features/nodesSlice"; export async function initEventListeners() { // This operation is in-expensive @@ -164,14 +164,14 @@ export async function buyXmr( "buy_xmr", bitcoin_change_address == null ? { - seller: providerToConcatenatedMultiAddr(seller), - monero_receive_address, - } + seller: providerToConcatenatedMultiAddr(seller), + monero_receive_address, + } : { - seller: providerToConcatenatedMultiAddr(seller), - monero_receive_address, - bitcoin_change_address, - }, + seller: providerToConcatenatedMultiAddr(seller), + monero_receive_address, + bitcoin_change_address, + }, ); } @@ -220,11 +220,8 @@ export async function listSellersAtRendezvousPoint( } export async function initializeContext() { - console.log("Prepare: Initializing context with settings"); - const network = getNetwork(); - const settings = store.getState().settings; - let statuses = store.getState().nodes.nodes; + const testnet = isTestnet(); // Initialize Tauri settings with null values const tauriSettings: TauriSettings = { @@ -232,29 +229,24 @@ export async function initializeContext() { monero_node_url: null, }; - // Set the first available node, if set - if (Object.keys(statuses.bitcoin).length === 0) { - await updateAllNodeStatuses(); - statuses = store.getState().nodes.nodes; + // If are missing any statuses, update them + if (Object.values(Blockchain).some(blockchain => + Object.values(store.getState().nodes.nodes[blockchain]).length < store.getState().settings.nodes[network][blockchain].length + )) { + try { + console.log("Updating node statuses"); + await updateAllNodeStatuses(); + } catch (e) { + logger.error(e, "Failed to update node statuses"); + } } + const { bitcoin: bitcoinNodes, monero: moneroNodes } = store.getState().nodes.nodes; - let firstAvailableElectrumNode = settings.nodes[network][Blockchain.Bitcoin] - .find(node => statuses.bitcoin[node] === true); - - if (firstAvailableElectrumNode !== undefined) - tauriSettings.electrum_rpc_url = firstAvailableElectrumNode; - else - logger.info("No custom Electrum node available, falling back to default."); + const firstAvailableElectrumNode = Object.keys(bitcoinNodes).find(node => bitcoinNodes[node] === true); + const firstAvailableMoneroNode = Object.keys(moneroNodes).find(node => moneroNodes[node] === true); - let firstAvailableMoneroNode = settings.nodes[network][Blockchain.Monero] - .find(node => statuses.monero[node] === true); - - if (firstAvailableMoneroNode !== undefined) - tauriSettings.monero_node_url = firstAvailableMoneroNode; - else - logger.info("No custom Monero node available, falling back to default."); - - const testnet = isTestnet(); + tauriSettings.electrum_rpc_url = firstAvailableElectrumNode ?? null; + tauriSettings.monero_node_url = firstAvailableMoneroNode ?? null; console.log("Initializing context with settings", tauriSettings); @@ -269,7 +261,7 @@ export async function getWalletDescriptor() { } export async function getMoneroNodeStatus(node: string): Promise { - const response =await invoke("check_monero_node", { + const response = await invoke("check_monero_node", { url: node, network: getNetworkName(), }); @@ -289,33 +281,27 @@ export async function getNodeStatus(url: string, blockchain: Blockchain): Promis switch (blockchain) { case Blockchain.Monero: return await getMoneroNodeStatus(url); case Blockchain.Bitcoin: return await getElectrumNodeStatus(url); - default: throw new Error(`Unknown blockchain: ${blockchain}`); } } +async function updateNodeStatus(node: string, blockchain: Blockchain) { + const status = await getNodeStatus(node, blockchain); + + store.dispatch(setStatus({ node, status, blockchain })); +} + export async function updateAllNodeStatuses() { const network = getNetwork(); const settings = store.getState().settings; - // We will update the statuses in batches - const newStatuses: Record> = { - [Blockchain.Bitcoin]: {}, - [Blockchain.Monero]: {}, - }; - // For all nodes, check if they are available and store the new status (in parallel) await Promise.all( Object.values(Blockchain).flatMap(blockchain => - settings.nodes[network][blockchain].map(async node => { - const status = await getNodeStatus(node, blockchain); - newStatuses[blockchain][node] = status; - }) + settings.nodes[network][blockchain].map(node => updateNodeStatus(node, blockchain)) ) ); - - // When we are done, we update the statuses in the store - store.dispatch(setStatuses(newStatuses)); } + export async function getMoneroAddresses(): Promise { return await invokeNoArgs("get_monero_addresses"); } diff --git a/src-gui/src/renderer/store/storeRenderer.ts b/src-gui/src/renderer/store/storeRenderer.ts index be2104789..201f3786f 100644 --- a/src-gui/src/renderer/store/storeRenderer.ts +++ b/src-gui/src/renderer/store/storeRenderer.ts @@ -3,8 +3,8 @@ import { persistReducer, persistStore } from "redux-persist"; import sessionStorage from "redux-persist/lib/storage/session"; import { reducers } from "store/combinedReducer"; import { createMainListeners } from "store/middleware/storeListener"; -import { LazyStore } from "@tauri-apps/plugin-store"; import { getNetworkName } from "store/config"; +import { LazyStore } from "@tauri-apps/plugin-store"; // Goal: Maintain application state across page reloads while allowing a clean slate on application restart // Settings are persisted across application restarts, while the rest of the state is cleared diff --git a/src-gui/src/store/config.ts b/src-gui/src/store/config.ts index a676c0673..fab95db49 100644 --- a/src-gui/src/store/config.ts +++ b/src-gui/src/store/config.ts @@ -47,7 +47,7 @@ export function getStubTestnetProvider(): ExtendedProviderStatus | null { export function getNetworkName(): string { if (isTestnet()) { return "Testnet"; - }else { + } else { return "Mainnet"; } } \ No newline at end of file diff --git a/src-gui/src/store/features/nodesSlice.ts b/src-gui/src/store/features/nodesSlice.ts index b11e0ffc3..4c0facb1b 100644 --- a/src-gui/src/store/features/nodesSlice.ts +++ b/src-gui/src/store/features/nodesSlice.ts @@ -6,21 +6,18 @@ export interface NodesSlice { } function initialState(): NodesSlice { - return { - nodes: { - [Blockchain.Bitcoin]: {}, - [Blockchain.Monero]: {}, - }, - } -} + return { + nodes: { + [Blockchain.Bitcoin]: {}, + [Blockchain.Monero]: {}, + }, + } +} const nodesSlice = createSlice({ name: "nodes", initialState: initialState(), reducers: { - setStatuses(slice, action: PayloadAction>>) { - slice.nodes = action.payload; - }, setStatus(slice, action: PayloadAction<{ node: string, status: boolean, @@ -29,13 +26,13 @@ const nodesSlice = createSlice({ slice.nodes[action.payload.blockchain][action.payload.node] = action.payload.status; }, resetStatuses(slice) { - slice.nodes = { - [Blockchain.Bitcoin]: {}, - [Blockchain.Monero]: {}, - } + slice.nodes = { + [Blockchain.Bitcoin]: {}, + [Blockchain.Monero]: {}, + } }, }, }); -export const { setStatus, setStatuses, resetStatuses } = nodesSlice.actions; +export const { setStatus, resetStatuses } = nodesSlice.actions; export default nodesSlice.reducer; diff --git a/src-gui/src/store/features/settingsSlice.ts b/src-gui/src/store/features/settingsSlice.ts index e10be1c8f..b9439a6bc 100644 --- a/src-gui/src/store/features/settingsSlice.ts +++ b/src-gui/src/store/features/settingsSlice.ts @@ -148,4 +148,5 @@ export const { setFetchFiatPrices, setFiatCurrency, } = alertsSlice.actions; + export default alertsSlice.reducer; diff --git a/src-gui/src/store/features/swapSlice.ts b/src-gui/src/store/features/swapSlice.ts index cd45130b9..d2acec5c7 100644 --- a/src-gui/src/store/features/swapSlice.ts +++ b/src-gui/src/store/features/swapSlice.ts @@ -24,14 +24,12 @@ export const swapSlice = createSlice({ // // Then we create a new swap state object that stores the current and previous events if (swap.state === null || action.payload.swap_id !== swap.state.swapId) { - console.log("Creating new swap state object"); swap.state = { curr: action.payload.event, prev: null, swapId: action.payload.swap_id, }; } else { - console.log("Updating existing swap state object"); swap.state.prev = swap.state.curr; swap.state.curr = action.payload.event; } diff --git a/src-gui/yarn.lock b/src-gui/yarn.lock index c7e65d16c..31cabec35 100644 --- a/src-gui/yarn.lock +++ b/src-gui/yarn.lock @@ -542,10 +542,10 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@reduxjs/toolkit@^2.2.6": - version "2.2.6" - resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.2.6.tgz#4a8356dad9d0c1ab255607a555d492168e0e3bc1" - integrity sha512-kH0r495c5z1t0g796eDQAkYbEQ3a1OLYN9o8jQQVZyKyw367pfRGS+qZLkHYvFHiUUdafpoSlQ2QYObIApjPWA== +"@reduxjs/toolkit@^2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.3.0.tgz#d00134634d6c1678e8563ac50026e429e3b64420" + integrity sha512-WC7Yd6cNGfHx8zf+iu+Q1UPTfEcXhQ+ATi7CV1hlrSAaQBdlPzg7Ww/wJHNQem7qG9rxmWoFCDCPubSvFObGzA== dependencies: immer "^10.0.3" redux "^5.0.1" diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index f5e6e5fb0..7a8942bad 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -1327,7 +1327,8 @@ impl CheckMoneroNodeArgs { static CLIENT: Lazy = Lazy::new(|| { reqwest::Client::builder() - .timeout(Duration::from_secs(30)) + // This function is called very frequently, so we set the timeout to be short + .timeout(Duration::from_secs(5)) .https_only(false) .build() .expect("reqwest client to work") diff --git a/swap/src/cli/event_loop.rs b/swap/src/cli/event_loop.rs index 45f704c50..2b6076805 100644 --- a/swap/src/cli/event_loop.rs +++ b/swap/src/cli/event_loop.rs @@ -343,6 +343,7 @@ impl EventLoop { if self.swarm.behaviour_mut().transfer_proof.send_response(response_channel, ()).is_err() { tracing::warn!("Failed to send acknowledgment to Alice that we have received the transfer proof"); } else { + tracing::info!("Sent acknowledgment to Alice that we have received the transfer proof"); self.pending_transfer_proof = OptionFuture::from(None); } },