diff --git a/src-gui/.gitignore b/src-gui/.gitignore index a1ee1b0c0..544dca014 100644 --- a/src-gui/.gitignore +++ b/src-gui/.gitignore @@ -28,4 +28,4 @@ dist-ssr src/models/tauriModel.ts # Env files -.env.* +.env* diff --git a/src-gui/package.json b/src-gui/package.json index 7b27834e4..c8da3570e 100644 --- a/src-gui/package.json +++ b/src-gui/package.json @@ -24,7 +24,7 @@ "@tauri-apps/plugin-clipboard-manager": "^2.0.0", "@tauri-apps/plugin-process": "^2.0.0", "@tauri-apps/plugin-shell": "^2.0.0", - "@tauri-apps/plugin-store": "2.1.0", + "@tauri-apps/plugin-store": "^2.1.0", "@tauri-apps/plugin-updater": "^2.0.0", "humanize-duration": "^3.32.1", "lodash": "^4.17.21", diff --git a/src-gui/src/renderer/api.ts b/src-gui/src/renderer/api.ts index e0438920e..687b66e94 100644 --- a/src-gui/src/renderer/api.ts +++ b/src-gui/src/renderer/api.ts @@ -5,6 +5,9 @@ // - and to submit feedback // - fetch currency rates from CoinGecko import { Alert, ExtendedProviderStatus } from "models/apiModel"; +import { store } from "./store/storeRenderer"; +import { setBtcPrice, setXmrBtcRate, setXmrPrice } from "store/features/ratesSlice"; +import { FiatCurrency } from "store/features/settingsSlice"; const API_BASE_URL = "https://api.unstoppableswap.net"; @@ -45,20 +48,20 @@ export async function submitFeedbackViaHttp( return responseBody.feedbackId; } -async function fetchCurrencyUsdPrice(currency: string): Promise { +async function fetchCurrencyPrice(currency: string, fiatCurrency: FiatCurrency): Promise { try { const response = await fetch( - `https://api.coingecko.com/api/v3/simple/price?ids=${currency}&vs_currencies=usd`, + `https://api.coingecko.com/api/v3/simple/price?ids=${currency}&vs_currencies=${fiatCurrency.toLowerCase()}`, ); const data = await response.json(); - return data[currency].usd; + return data[currency][fiatCurrency.toLowerCase()]; } catch (error) { console.error(`Error fetching ${currency} price:`, error); throw error; } } -export async function fetchXmrBtcRate(): Promise { +async function fetchXmrBtcRate(): Promise { try { const response = await fetch('https://api.kraken.com/0/public/Ticker?pair=XMRXBT'); const data = await response.json(); @@ -78,10 +81,35 @@ export async function fetchXmrBtcRate(): Promise { } -export async function fetchBtcPrice(): Promise { - return fetchCurrencyUsdPrice("bitcoin"); +async function fetchBtcPrice(fiatCurrency: FiatCurrency): Promise { + return fetchCurrencyPrice("bitcoin", fiatCurrency); } -export async function fetchXmrPrice(): Promise { - return fetchCurrencyUsdPrice("monero"); -} \ No newline at end of file +async function fetchXmrPrice(fiatCurrency: FiatCurrency): Promise { + return fetchCurrencyPrice("monero", fiatCurrency); +} + +/** + * If enabled by the user, fetch the XMR, BTC and XMR/BTC rates + * and store them in the Redux store. + */ +export async function updateRates(): Promise { + const settings = store.getState().settings; + if (!settings.fetchFiatPrices) + return; + + try { + const xmrBtcRate = await fetchXmrBtcRate(); + store.dispatch(setXmrBtcRate(xmrBtcRate)); + + const btcPrice = await fetchBtcPrice(settings.fiatCurrency); + store.dispatch(setBtcPrice(btcPrice)); + + const xmrPrice = await fetchXmrPrice(settings.fiatCurrency); + store.dispatch(setXmrPrice(xmrPrice)); + + console.log(`Fetched rates for ${settings.fiatCurrency}`); + } catch (error) { + console.error("Error fetching rates:", error); + } +} diff --git a/src-gui/src/renderer/components/App.tsx b/src-gui/src/renderer/components/App.tsx index edc27366b..f9972b467 100644 --- a/src-gui/src/renderer/components/App.tsx +++ b/src-gui/src/renderer/components/App.tsx @@ -1,6 +1,5 @@ import { Box, CssBaseline, makeStyles } from "@material-ui/core"; -import { indigo } from "@material-ui/core/colors"; -import { createTheme, ThemeProvider } from "@material-ui/core/styles"; +import { ThemeProvider } from "@material-ui/core/styles"; import "@tauri-apps/plugin-shell"; import { Route, MemoryRouter as Router, Routes } from "react-router-dom"; import Navigation, { drawerWidth } from "./navigation/Navigation"; @@ -9,15 +8,17 @@ import HistoryPage from "./pages/history/HistoryPage"; import SwapPage from "./pages/swap/SwapPage"; import WalletPage from "./pages/wallet/WalletPage"; import GlobalSnackbarProvider from "./snackbar/GlobalSnackbarProvider"; -import { useEffect } from "react"; -import { fetchProvidersViaHttp, fetchAlertsViaHttp, fetchXmrPrice, fetchBtcPrice, fetchXmrBtcRate } from "renderer/api"; -import { initEventListeners } from "renderer/rpc"; -import { store } from "renderer/store/storeRenderer"; import UpdaterDialog from "./modal/updater/UpdaterDialog"; -import { setAlerts } from "store/features/alertsSlice"; -import { setRegistryProviders, registryConnectionFailed } from "store/features/providersSlice"; -import { setXmrPrice, setBtcPrice, setXmrBtcRate } from "store/features/ratesSlice"; +import { useSettings } from "store/hooks"; +import { themes } from "./theme"; +import { initEventListeners, updateAllNodeStatuses } from "renderer/rpc"; +import { fetchAlertsViaHttp, fetchProvidersViaHttp, updateRates } from "renderer/api"; +import { store } from "renderer/store/storeRenderer"; import logger from "utils/logger"; +import { setAlerts } from "store/features/alertsSlice"; +import { setRegistryProviders } from "store/features/providersSlice"; +import { registryConnectionFailed } from "store/features/providersSlice"; +import { useEffect } from "react"; const useStyles = makeStyles((theme) => ({ innerContent: { @@ -28,57 +29,44 @@ const useStyles = makeStyles((theme) => ({ }, })); -const theme = createTheme({ - palette: { - type: "dark", - primary: { - main: "#f4511e", - }, - secondary: indigo, - }, - typography: { - overline: { - textTransform: "none", // This prevents the text from being all caps - }, - }, -}); - -function InnerContent() { - const classes = useStyles(); - - return ( - - - } /> - } /> - } /> - } /> - } /> - - - ); -} - export default function App() { useEffect(() => { fetchInitialData(); initEventListeners(); }, []); + const theme = useSettings((s) => s.theme); + return ( - + - + ); } +function InnerContent() { + const classes = useStyles(); + + return ( + + + } /> + } /> + } /> + } /> + } /> + + + ); +} + async function fetchInitialData() { try { const providerList = await fetchProvidersViaHttp(); @@ -94,30 +82,31 @@ async function fetchInitialData() { } try { - const alerts = await fetchAlertsViaHttp(); - store.dispatch(setAlerts(alerts)); - logger.info({ alerts }, "Fetched alerts via UnstoppableSwap HTTP API"); + await updateAllNodeStatuses() } catch (e) { - logger.error(e, "Failed to fetch alerts via UnstoppableSwap HTTP API"); + logger.error(e, "Failed to update node statuses") } - try { - const xmrPrice = await fetchXmrPrice(); - store.dispatch(setXmrPrice(xmrPrice)); - logger.info({ xmrPrice }, "Fetched XMR price"); + // Update node statuses every 2 minutes + const STATUS_UPDATE_INTERVAL = 2 * 60 * 1_000; + setInterval(updateAllNodeStatuses, STATUS_UPDATE_INTERVAL); - const btcPrice = await fetchBtcPrice(); - store.dispatch(setBtcPrice(btcPrice)); - logger.info({ btcPrice }, "Fetched BTC price"); + try { + const alerts = await fetchAlertsViaHttp(); + store.dispatch(setAlerts(alerts)); + logger.info({ alerts }, "Fetched alerts via UnstoppableSwap HTTP API"); } catch (e) { - logger.error(e, "Error retrieving fiat prices"); + logger.error(e, "Failed to fetch alerts via UnstoppableSwap HTTP API"); } try { - const xmrBtcRate = await fetchXmrBtcRate(); - store.dispatch(setXmrBtcRate(xmrBtcRate)); - logger.info({ xmrBtcRate }, "Fetched XMR/BTC rate"); + await updateRates(); + logger.info("Fetched XMR/BTC rate"); } catch (e) { logger.error(e, "Error retrieving XMR/BTC rate"); } + + // Update the rates every 5 minutes (to respect the coingecko rate limit) + const RATE_UPDATE_INTERVAL = 5 * 60 * 1_000; + setInterval(updateRates, RATE_UPDATE_INTERVAL); } diff --git a/src-gui/src/renderer/components/alert/DaemonStatusAlert.tsx b/src-gui/src/renderer/components/alert/DaemonStatusAlert.tsx index f9bf2b489..a06e2f233 100644 --- a/src-gui/src/renderer/components/alert/DaemonStatusAlert.tsx +++ b/src-gui/src/renderer/components/alert/DaemonStatusAlert.tsx @@ -50,7 +50,7 @@ export default function DaemonStatusAlert() { diff --git a/src-gui/src/renderer/components/modal/provider/ProviderInfo.tsx b/src-gui/src/renderer/components/modal/provider/ProviderInfo.tsx index 525f72215..7e2bd7f99 100644 --- a/src-gui/src/renderer/components/modal/provider/ProviderInfo.tsx +++ b/src-gui/src/renderer/components/modal/provider/ProviderInfo.tsx @@ -26,19 +26,21 @@ const useStyles = makeStyles((theme) => ({ }, })); -function ProviderSpreadChip({ provider }: { provider: ExtendedProviderStatus }) { - const xmrBtcPrice = useAppSelector(s => s.rates?.xmrBtcRate); - - if (xmrBtcPrice === null) { +/** + * A chip that displays the markup of the provider's exchange rate compared to the market rate. + */ +function ProviderMarkupChip({ provider }: { provider: ExtendedProviderStatus }) { + const marketExchangeRate = useAppSelector(s => s.rates?.xmrBtcRate); + if (marketExchangeRate === null) return null; - } - const providerPrice = satsToBtc(provider.price); - const spread = ((providerPrice - xmrBtcPrice) / xmrBtcPrice) * 100; + const providerExchangeRate = satsToBtc(provider.price); + /** The markup of the exchange rate compared to the market rate in percent */ + const markup = (providerExchangeRate - marketExchangeRate) / marketExchangeRate * 100; return ( - - + + ); @@ -74,8 +76,8 @@ export default function ProviderInfo({ {provider.uptime && ( - - + + )} {provider.age ? ( @@ -93,11 +95,11 @@ export default function ProviderInfo({ )} {isOutdated && ( - + } color="primary" /> )} - + ); diff --git a/src-gui/src/renderer/components/modal/swap/InfoBox.tsx b/src-gui/src/renderer/components/modal/swap/InfoBox.tsx index 9e479ae6c..4a08af362 100644 --- a/src-gui/src/renderer/components/modal/swap/InfoBox.tsx +++ b/src-gui/src/renderer/components/modal/swap/InfoBox.tsx @@ -8,6 +8,7 @@ import { import { ReactNode } from "react"; type Props = { + id?: string; title: ReactNode; mainContent: ReactNode; additionalContent: ReactNode; @@ -31,6 +32,7 @@ const useStyles = makeStyles((theme) => ({ })); export default function InfoBox({ + id = null, title, mainContent, additionalContent, @@ -40,7 +42,7 @@ export default function InfoBox({ const classes = useStyles(); return ( - + {title} {icon} diff --git a/src-gui/src/renderer/components/navigation/NavigationHeader.tsx b/src-gui/src/renderer/components/navigation/NavigationHeader.tsx index 848633e59..d60afbf66 100644 --- a/src-gui/src/renderer/components/navigation/NavigationHeader.tsx +++ b/src-gui/src/renderer/components/navigation/NavigationHeader.tsx @@ -21,7 +21,7 @@ export default function NavigationHeader() { - + diff --git a/src-gui/src/renderer/components/other/Units.tsx b/src-gui/src/renderer/components/other/Units.tsx index 41c787135..d22a8a1f6 100644 --- a/src-gui/src/renderer/components/other/Units.tsx +++ b/src-gui/src/renderer/components/other/Units.tsx @@ -8,16 +8,18 @@ export function AmountWithUnit({ amount, unit, fixedPrecision, - dollarRate, + exchangeRate, }: { amount: Amount; unit: string; fixedPrecision: number; - dollarRate?: Amount; + exchangeRate?: Amount; }) { + const fetchFiatPrices = useAppSelector((state) => state.settings.fetchFiatPrices); + const fiatCurrency = useAppSelector((state) => state.settings.fiatCurrency); const title = - dollarRate != null && amount != null - ? `≈ $${(dollarRate * amount).toFixed(2)}` + fetchFiatPrices && exchangeRate != null && amount != null && fiatCurrency != null + ? `≈ ${(exchangeRate * amount).toFixed(2)} ${fiatCurrency}` : ""; return ( @@ -33,31 +35,31 @@ export function AmountWithUnit({ } AmountWithUnit.defaultProps = { - dollarRate: null, + exchangeRate: null, }; export function BitcoinAmount({ amount }: { amount: Amount }) { - const btcUsdRate = useAppSelector((state) => state.rates.btcPrice); + const btcRate = useAppSelector((state) => state.rates.btcPrice); return ( ); } export function MoneroAmount({ amount }: { amount: Amount }) { - const xmrUsdRate = useAppSelector((state) => state.rates.xmrPrice); + const xmrRate = useAppSelector((state) => state.rates.xmrPrice); return ( ); } diff --git a/src-gui/src/renderer/components/other/ValidatedTextField.tsx b/src-gui/src/renderer/components/other/ValidatedTextField.tsx index 530d805f4..bae766e9f 100644 --- a/src-gui/src/renderer/components/other/ValidatedTextField.tsx +++ b/src-gui/src/renderer/components/other/ValidatedTextField.tsx @@ -6,6 +6,7 @@ interface ValidatedTextFieldProps extends Omit boolean; onValidatedChange: (value: string | null) => void; allowEmpty?: boolean; + noErrorWhenEmpty?: boolean; helperText?: string; } @@ -17,6 +18,7 @@ export default function ValidatedTextField({ helperText = "Invalid input", variant = "standard", allowEmpty = false, + noErrorWhenEmpty = false, ...props }: ValidatedTextFieldProps) { const [inputValue, setInputValue] = useState(value || ""); @@ -39,7 +41,7 @@ export default function ValidatedTextField({ setInputValue(value || ""); }, [value]); - const isError = allowEmpty && inputValue === "" ? false : !isValid(inputValue); + const isError = allowEmpty && inputValue === "" || inputValue === "" && noErrorWhenEmpty ? false : !isValid(inputValue); return ( } additionalContent={ - We rely on generous donors like you to keep development moving - forward. To bring Atomic Swaps to life, we need resources. If you have - the possibility, please consider making a donation to the project. All - funds will be used to support contributors and critical - infrastructure. +

+ As part of the Monero Community Crowdfunding System (CCS), we received funding for 6 months of full-time development by + generous donors from the Monero community (link). +

+

+ If you want to support our effort event further, you can do so at this address. +

} /> diff --git a/src-gui/src/renderer/components/pages/help/HelpPage.tsx b/src-gui/src/renderer/components/pages/help/HelpPage.tsx index a76d12224..9299d7196 100644 --- a/src-gui/src/renderer/components/pages/help/HelpPage.tsx +++ b/src-gui/src/renderer/components/pages/help/HelpPage.tsx @@ -5,6 +5,8 @@ import FeedbackInfoBox from "./FeedbackInfoBox"; import DaemonControlBox from "./DaemonControlBox"; import SettingsBox from "./SettingsBox"; import ExportDataBox from "./ExportDataBox"; +import { useLocation } from "react-router-dom"; +import { useEffect } from "react"; const useStyles = makeStyles((theme) => ({ outer: { display: "flex", @@ -16,13 +18,21 @@ const useStyles = makeStyles((theme) => ({ export default function HelpPage() { const classes = useStyles(); + const location = useLocation(); + + useEffect(() => { + if (location.hash) { + const element = document.getElementById(location.hash.slice(1)); + element?.scrollIntoView({ behavior: "smooth" }); + } + }, [location]); return ( - + diff --git a/src-gui/src/renderer/components/pages/help/SettingsBox.tsx b/src-gui/src/renderer/components/pages/help/SettingsBox.tsx index 8e7704772..a9727d1ad 100644 --- a/src-gui/src/renderer/components/pages/help/SettingsBox.tsx +++ b/src-gui/src/renderer/components/pages/help/SettingsBox.tsx @@ -9,19 +9,41 @@ import { Box, makeStyles, Tooltip, + Select, + MenuItem, + TableHead, + Paper, + Button, + Dialog, + DialogContent, + DialogActions, + DialogTitle, + useTheme, Switch, } from "@material-ui/core"; import InfoBox from "renderer/components/modal/swap/InfoBox"; import { + removeNode, resetSettings, - setElectrumRpcUrl, - setMoneroNodeUrl, + setFetchFiatPrices, + setFiatCurrency, } from "store/features/settingsSlice"; -import { useAppDispatch, useSettings } from "store/hooks"; +import { + addNode, + Blockchain, + FiatCurrency, + moveUpNode, + Network, + setTheme, +} from "store/features/settingsSlice"; +import { useAppDispatch, useAppSelector, useNodes, useSettings } from "store/hooks"; import ValidatedTextField from "renderer/components/other/ValidatedTextField"; -import RefreshIcon from "@material-ui/icons/Refresh"; import HelpIcon from '@material-ui/icons/HelpOutline'; -import { ReactNode } from "react"; +import { ReactNode, useState } from "react"; +import { Theme } from "renderer/components/theme"; +import { Add, ArrowUpward, Delete, Edit, HourglassEmpty } from "@material-ui/icons"; +import { getNetwork } from "store/config"; +import { currencySymbol } from "utils/formatUtils"; const PLACEHOLDER_ELECTRUM_RPC_URL = "ssl://blockstream.info:700"; const PLACEHOLDER_MONERO_NODE_URL = "http://xmr-node.cakewallet.com:18081"; @@ -34,59 +56,155 @@ const useStyles = makeStyles((theme) => ({ } })); +/** + * The settings box, containing the settings for the GUI. + */ export default function SettingsBox() { - const dispatch = useAppDispatch(); const classes = useStyles(); - + const theme = useTheme(); + return ( Settings - { - dispatch(resetSettings()); - }} - > - -
} - additionalContent={ - - - - - - -
-
- } mainContent={ - Some of these settings require a restart to take effect. + Customize the settings of the GUI. + Some of these require a restart to take effect. } + additionalContent={ + <> + {/* Table containing the settings */} + + + + + + + + +
+
+ {/* Reset button with a bit of spacing */} + + + + } icon={null} loading={false} /> ); } -// URL validation function, forces the URL to be in the format of "protocol://host:port/" +/** + * A button that allows you to reset the settings. + * Opens a modal that asks for confirmation first. + */ +function ResetButton() { + const dispatch = useAppDispatch(); + const [modalOpen, setModalOpen] = useState(false); + + const onReset = () => { + dispatch(resetSettings()); + setModalOpen(false); + }; + + return ( + <> + + setModalOpen(false)}> + Reset Settings + Are you sure you want to reset the settings? + + + + + + + ) +} + +/** + * A setting that allows you to enable or disable the fetching of fiat prices. + */ +function FetchFiatPricesSetting() { + const fetchFiatPrices = useSettings((s) => s.fetchFiatPrices); + const dispatch = useAppDispatch(); + + return ( + <> + + + + + + dispatch(setFetchFiatPrices(event.currentTarget.checked))} + /> + + + {fetchFiatPrices ? : <>} + + ); +} + +/** + * A setting that allows you to select the fiat currency to display prices in. + */ +function FiatCurrencySetting() { + const fiatCurrency = useSettings((s) => s.fiatCurrency); + const dispatch = useAppDispatch(); + const onChange = (e: React.ChangeEvent<{ value: unknown }>) => + dispatch(setFiatCurrency(e.target.value as FiatCurrency)); + + return ( + + + + + + + + + ); +} + +/** + * URL validation function, forces the URL to be in the format of "protocol://host:port/" + */ function isValidUrl(url: string, allowedProtocols: string[]): boolean { const urlPattern = new RegExp(`^(${allowedProtocols.join("|")})://[^\\s]+:\\d+/?$`); return urlPattern.test(url); } +/** + * A setting that allows you to select the Electrum RPC URL to use. + */ function ElectrumRpcUrlSetting() { - const electrumRpcUrl = useSettings((s) => s.electrum_rpc_url); - const dispatch = useAppDispatch(); + const [tableVisible, setTableVisible] = useState(false); + const network = getNetwork(); - function isValid(url: string): boolean { - return isValidUrl(url, ["ssl", "tcp"]); - } + const isValid = (url: string) => isValidUrl(url, ["ssl", "tcp"]); return ( @@ -94,22 +212,27 @@ function ElectrumRpcUrlSetting() { - setTableVisible(true)} + > + {} + + {tableVisible ? setTableVisible(false)} + network={network} + blockchain={Blockchain.Bitcoin} isValid={isValid} - onValidatedChange={(value) => { - dispatch(setElectrumRpcUrl(value)); - }} - fullWidth placeholder={PLACEHOLDER_ELECTRUM_RPC_URL} - allowEmpty - /> + /> : <>} ); } +/** + * A label for a setting, with a tooltip icon. + */ function SettingLabel({ label, tooltip }: { label: ReactNode, tooltip: string | null }) { return @@ -123,32 +246,239 @@ function SettingLabel({ label, tooltip }: { label: ReactNode, tooltip: string | } +/** + * A setting that allows you to select the Monero Node URL to use. + */ function MoneroNodeUrlSetting() { - const moneroNodeUrl = useSettings((s) => s.monero_node_url); - const dispatch = useAppDispatch(); + const network = getNetwork(); + const [tableVisible, setTableVisible] = useState(false); - function isValid(url: string): boolean { - return isValidUrl(url, ["http"]); - } + const isValid = (url: string) => isValidUrl(url, ["http"]); return ( - + - setTableVisible(!tableVisible)} + > + + + {tableVisible ? setTableVisible(false)} + network={network} + blockchain={Blockchain.Monero} isValid={isValid} - onValidatedChange={(value) => { - dispatch(setMoneroNodeUrl(value)); - }} - fullWidth placeholder={PLACEHOLDER_MONERO_NODE_URL} - allowEmpty - /> + /> : <>} + + + ); +} + +/** + * A setting that allows you to select the theme of the GUI. + */ +function ThemeSetting() { + const theme = useAppSelector((s) => s.settings.theme); + const dispatch = useAppDispatch(); + + return ( + + + + + + ); +} + +/** + * A modal containing a NodeTable for a given network and blockchain. + * It allows you to add, remove, and move nodes up the list. + */ +function NodeTableModal({ + open, + onClose, + network, + isValid, + placeholder, + blockchain +}: { + network: Network; + blockchain: Blockchain; + isValid: (url: string) => boolean; + placeholder: string; + open: boolean; + onClose: () => void; +}) { + return ( + + Available Nodes + + + When the daemon is started, it will attempt to connect to the first available {blockchain} node in this list. + If you leave this field empty or all nodes are unavailable, it will choose from a list of known nodes at random. + Requires a restart to take effect. + + + + + + + + ) +} + +/** + * 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. + * It fetches the nodes from the store (nodesSlice) and the statuses of all nodes every 15 seconds. + */ +function NodeTable({ + network, + blockchain, + isValid, + placeholder, +}: { + network: Network, + blockchain: Blockchain, + isValid: (url: string) => boolean, + placeholder: string, +}) { + const availableNodes = useSettings((s) => s.nodes[network][blockchain]); + const currentNode = availableNodes[0]; + 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 })); + setNewNode(""); + } + + const onRemoveNode = (node: string) => + dispatch(removeNode({ network, type: blockchain, node })); + + const onMoveUpNode = (node: string) => + dispatch(moveUpNode({ network, type: blockchain, node })); + + const moveUpButton = (node: string) => { + if (currentNode === node) + return <>; + + return ( + + onMoveUpNode(node)}> + + + + ) + } + + return ( + + + {/* Table header row */} + + + Node URL + Status + Actions + + + + {/* Table body rows: one for each node */} + {availableNodes.map((node, index) => ( + + {/* Node URL */} + + {node} + + {/* Node status icon */} + + {/* Remove and move buttons */} + + + onRemoveNode(node)} + children={} + />} + /> + {moveUpButton(node)} + + + + ))} + {/* Last row: add a new node */} + + + + + + + + + + + + + + +
+
+ ) } \ No newline at end of file diff --git a/src-gui/src/renderer/components/pages/swap/SwapWidget.tsx b/src-gui/src/renderer/components/pages/swap/SwapWidget.tsx index de3c2a154..bac3c1cf3 100644 --- a/src-gui/src/renderer/components/pages/swap/SwapWidget.tsx +++ b/src-gui/src/renderer/components/pages/swap/SwapWidget.tsx @@ -136,7 +136,7 @@ function HasProviderSwapWidget({ <TextField - label="Send" + label="For this many BTC" size="medium" variant="outlined" value={btcFieldValue} @@ -152,7 +152,7 @@ function HasProviderSwapWidget({ <ArrowDownwardIcon fontSize="small" /> </Box> <TextField - label="Receive" + label="You'd receive that many XMR" variant="outlined" size="medium" value={xmrFieldValue.toFixed(6)} diff --git a/src-gui/src/renderer/components/pages/wallet/WithdrawWidget.tsx b/src-gui/src/renderer/components/pages/wallet/WithdrawWidget.tsx index e480eb3ef..32e5933ed 100644 --- a/src-gui/src/renderer/components/pages/wallet/WithdrawWidget.tsx +++ b/src-gui/src/renderer/components/pages/wallet/WithdrawWidget.tsx @@ -1,6 +1,5 @@ import { Box, Button, makeStyles, Typography } from "@material-ui/core"; import SendIcon from "@material-ui/icons/Send"; -import { RpcMethod } from "models/rpcModel"; import { useState } from "react"; import { SatsAmount } from "renderer/components/other/Units"; import { useAppSelector } from "store/hooks"; diff --git a/src-gui/src/renderer/components/theme.tsx b/src-gui/src/renderer/components/theme.tsx new file mode 100644 index 000000000..3359e9d37 --- /dev/null +++ b/src-gui/src/renderer/components/theme.tsx @@ -0,0 +1,56 @@ +import { createTheme } from "@material-ui/core"; +import { indigo } from "@material-ui/core/colors"; + +export enum Theme { + Light = "light", + Dark = "dark", + Darker = "darker" +} + +const darkTheme = createTheme({ + palette: { + type: "dark", + primary: { + main: "#f4511e", // Monero orange + }, + secondary: indigo, + }, + typography: { + overline: { + textTransform: "none", // This prevents the text from being all caps + fontFamily: "monospace" + }, + }, +}); + +const lightTheme = createTheme({ + ...darkTheme, + palette: { + type: "light", + primary: { + main: "#f4511e", // Monero orange + }, + secondary: indigo, + }, +}); + +const darkerTheme = createTheme({ + ...darkTheme, + palette: { + type: 'dark', + primary: { + main: "#f4511e", + }, + secondary: indigo, + background: { + default: "#080808", + paper: "#181818", + }, + }, +}); + +export const themes = { + [Theme.Dark]: darkTheme, + [Theme.Light]: lightTheme, + [Theme.Darker]: darkerTheme, +}; diff --git a/src-gui/src/renderer/index.tsx b/src-gui/src/renderer/index.tsx index 7297b36b0..6246c4d28 100644 --- a/src-gui/src/renderer/index.tsx +++ b/src-gui/src/renderer/index.tsx @@ -1,20 +1,6 @@ import { createRoot } from "react-dom/client"; import { Provider } from "react-redux"; import { PersistGate } from "redux-persist/integration/react"; -import { setAlerts } from "store/features/alertsSlice"; -import { - registryConnectionFailed, - setRegistryProviders, -} from "store/features/providersSlice"; -import { setBtcPrice, setXmrBtcRate, setXmrPrice } from "store/features/ratesSlice"; -import logger from "../utils/logger"; -import { - fetchAlertsViaHttp, - fetchBtcPrice, - fetchProvidersViaHttp, - fetchXmrBtcRate, - fetchXmrPrice, -} from "./api"; import App from "./components/App"; import { persistor, store } from "./store/storeRenderer"; @@ -27,4 +13,4 @@ root.render( <App /> </PersistGate> </Provider>, -); \ No newline at end of file +); diff --git a/src-gui/src/renderer/rpc.ts b/src-gui/src/renderer/rpc.ts index 803cbc1ab..0558055be 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -22,6 +22,11 @@ import { TauriTimelockChangeEvent, GetSwapInfoArgs, ExportBitcoinWalletResponse, + CheckMoneroNodeArgs, + CheckMoneroNodeResponse, + TauriSettings, + CheckElectrumNodeArgs, + CheckElectrumNodeResponse, GetMoneroAddressesResponse, } from "models/tauriModel"; import { @@ -38,7 +43,9 @@ import { providerToConcatenatedMultiAddr } from "utils/multiAddrUtils"; import { MoneroRecoveryResponse } from "models/rpcModel"; import { ListSellersResponse } from "../models/tauriModel"; import logger from "utils/logger"; -import { isTestnet } from "store/config"; +import { getNetwork, getNetworkName, isTestnet } from "store/config"; +import { Blockchain, Network } from "store/features/settingsSlice"; +import { resetStatuses, setPromise, setStatus, setStatuses } from "store/features/nodesSlice"; export async function initEventListeners() { // This operation is in-expensive @@ -50,8 +57,13 @@ export async function initEventListeners() { // Warning: If we reload the page while the Context is being initialized, this function will throw an error initializeContext().catch((e) => { logger.error(e, "Failed to initialize context on page load. This might be because we reloaded the page while the context was being initialized"); + // Wait a short time before retrying + setTimeout(() => { + initializeContext().catch((e) => { + logger.error(e, "Failed to initialize context even after retry"); + }); + }, 2000); // 2 second delay }); - initializeContext(); } listen<TauriSwapProgressEventWrapper>("swap-progress-update", (event) => { @@ -208,11 +220,46 @@ 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; + + // Initialize Tauri settings with null values + const tauriSettings: TauriSettings = { + electrum_rpc_url: null, + 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; + } + + 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."); + + 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(); + console.log("Initializing context with settings", tauriSettings); + await invokeUnsafe<void>("initialize_context", { - settings, + settings: tauriSettings, testnet, }); } @@ -221,6 +268,54 @@ export async function getWalletDescriptor() { return await invokeNoArgs<ExportBitcoinWalletResponse>("get_wallet_descriptor"); } +export async function getMoneroNodeStatus(node: string): Promise<boolean> { + const response =await invoke<CheckMoneroNodeArgs, CheckMoneroNodeResponse>("check_monero_node", { + url: node, + network: getNetworkName(), + }); + + return response.available; +} + +export async function getElectrumNodeStatus(url: string): Promise<boolean> { + const response = await invoke<CheckElectrumNodeArgs, CheckElectrumNodeResponse>("check_electrum_node", { + url, + }); + + return response.available; +} + +export async function getNodeStatus(url: string, blockchain: Blockchain): Promise<boolean> { + switch (blockchain) { + case Blockchain.Monero: return await getMoneroNodeStatus(url); + case Blockchain.Bitcoin: return await getElectrumNodeStatus(url); + default: throw new Error(`Unknown blockchain: ${blockchain}`); + } +} + +export async function updateAllNodeStatuses() { + const network = getNetwork(); + const settings = store.getState().settings; + + // We will update the statuses in batches + const newStatuses: Record<Blockchain, Record<string, boolean>> = { + [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; + }) + ) + ); + + // When we are done, we update the statuses in the store + store.dispatch(setStatuses(newStatuses)); +} export async function getMoneroAddresses(): Promise<GetMoneroAddressesResponse> { return await invokeNoArgs<GetMoneroAddressesResponse>("get_monero_addresses"); -} \ No newline at end of file +} diff --git a/src-gui/src/renderer/store/storeRenderer.ts b/src-gui/src/renderer/store/storeRenderer.ts index 8dbbfd74b..be2104789 100644 --- a/src-gui/src/renderer/store/storeRenderer.ts +++ b/src-gui/src/renderer/store/storeRenderer.ts @@ -18,7 +18,7 @@ const rootPersistConfig = { }; // Use Tauri's store plugin for persistent settings -const tauriStore = new LazyStore(`${getNetworkName()}_settings.bin`); +const tauriStore = new LazyStore("settings.bin"); // Configure how settings are stored and retrieved using Tauri's storage const settingsPersistConfig = { diff --git a/src-gui/src/store/combinedReducer.ts b/src-gui/src/store/combinedReducer.ts index e0490cf7a..3417cf56e 100644 --- a/src-gui/src/store/combinedReducer.ts +++ b/src-gui/src/store/combinedReducer.ts @@ -5,7 +5,7 @@ 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"; export const reducers = { swap: swapReducer, providers: providersSlice, @@ -14,4 +14,5 @@ export const reducers = { alerts: alertsSlice, rates: ratesSlice, settings: settingsSlice, + nodes: nodesSlice, }; diff --git a/src-gui/src/store/config.ts b/src-gui/src/store/config.ts index 87b69dbf0..a676c0673 100644 --- a/src-gui/src/store/config.ts +++ b/src-gui/src/store/config.ts @@ -1,9 +1,18 @@ import { ExtendedProviderStatus } from "models/apiModel"; import { splitPeerIdFromMultiAddress } from "utils/parseUtils"; import { getMatches } from '@tauri-apps/plugin-cli'; +import { Network } from "./features/settingsSlice"; const matches = await getMatches(); +export function getNetwork(): Network { + if (isTestnet()) { + return Network.Testnet; + } else { + return Network.Mainnet; + } +} + export function isTestnet() { return matches.args.testnet?.value === true } diff --git a/src-gui/src/store/features/nodesSlice.ts b/src-gui/src/store/features/nodesSlice.ts new file mode 100644 index 000000000..b11e0ffc3 --- /dev/null +++ b/src-gui/src/store/features/nodesSlice.ts @@ -0,0 +1,41 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { Blockchain } from "./settingsSlice"; + +export interface NodesSlice { + nodes: Record<Blockchain, Record<string, boolean>>; +} + +function initialState(): NodesSlice { + return { + nodes: { + [Blockchain.Bitcoin]: {}, + [Blockchain.Monero]: {}, + }, + } +} + +const nodesSlice = createSlice({ + name: "nodes", + initialState: initialState(), + reducers: { + setStatuses(slice, action: PayloadAction<Record<Blockchain, Record<string, boolean>>>) { + slice.nodes = action.payload; + }, + setStatus(slice, action: PayloadAction<{ + node: string, + status: boolean, + blockchain: Blockchain, + }>) { + slice.nodes[action.payload.blockchain][action.payload.node] = action.payload.status; + }, + resetStatuses(slice) { + slice.nodes = { + [Blockchain.Bitcoin]: {}, + [Blockchain.Monero]: {}, + } + }, + }, +}); + +export const { setStatus, setStatuses, resetStatuses } = nodesSlice.actions; +export default nodesSlice.reducer; diff --git a/src-gui/src/store/features/ratesSlice.ts b/src-gui/src/store/features/ratesSlice.ts index fbac52f91..85be6b29b 100644 --- a/src-gui/src/store/features/ratesSlice.ts +++ b/src-gui/src/store/features/ratesSlice.ts @@ -28,9 +28,14 @@ const ratesSlice = createSlice({ setXmrBtcRate: (state, action: PayloadAction<number>) => { state.xmrBtcRate = action.payload; }, + resetRates: (state) => { + state.btcPrice = null; + state.xmrPrice = null; + state.xmrBtcRate = null; + }, }, }); -export const { setBtcPrice, setXmrPrice, setXmrBtcRate } = ratesSlice.actions; +export const { setBtcPrice, setXmrPrice, setXmrBtcRate, resetRates } = ratesSlice.actions; export default ratesSlice.reducer; diff --git a/src-gui/src/store/features/rpcSlice.ts b/src-gui/src/store/features/rpcSlice.ts index b5d3fd503..a65fa61fd 100644 --- a/src-gui/src/store/features/rpcSlice.ts +++ b/src-gui/src/store/features/rpcSlice.ts @@ -4,7 +4,6 @@ import { TauriLogEvent, GetSwapInfoResponse, TauriContextStatusEvent, - TauriDatabaseStateEvent, TauriTimelockChangeEvent, } from "models/tauriModel"; import { MoneroRecoveryResponse } from "../../models/rpcModel"; diff --git a/src-gui/src/store/features/settingsSlice.ts b/src-gui/src/store/features/settingsSlice.ts index b63675969..e10be1c8f 100644 --- a/src-gui/src/store/features/settingsSlice.ts +++ b/src-gui/src/store/features/settingsSlice.ts @@ -1,38 +1,151 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { TauriSettings } from "models/tauriModel"; +import { Theme } from "renderer/components/theme"; -const initialState: TauriSettings = { - electrum_rpc_url: null, - monero_node_url: null, +export interface SettingsState { + /// This is an ordered list of node urls for each network and blockchain + nodes: Record<Network, Record<Blockchain, string[]>>; + /// Which theme to use + theme: Theme; + /// Whether to fetch fiat prices from the internet + fetchFiatPrices: boolean; + fiatCurrency: FiatCurrency; +} + +export enum FiatCurrency { + Usd = "USD", + Eur = "EUR", + Gbp = "GBP", + Chf = "CHF", + Jpy = "JPY", + // the following are copied from the coin gecko API and claude, not sure if they all work + Aed = "AED", + Ars = "ARS", + Aud = "AUD", + Bdt = "BDT", + Bhd = "BHD", + Bmd = "BMD", + Brl = "BRL", + Cad = "CAD", + Clp = "CLP", + Cny = "CNY", + Czk = "CZK", + Dkk = "DKK", + Gel = "GEL", + Hkd = "HKD", + Huf = "HUF", + Idr = "IDR", + Ils = "ILS", + Inr = "INR", + Krw = "KRW", + Kwd = "KWD", + Lkr = "LKR", + Mmk = "MMK", + Mxn = "MXN", + Myr = "MYR", + Ngn = "NGN", + Nok = "NOK", + Nzd = "NZD", + Php = "PHP", + Pkr = "PKR", + Pln = "PLN", + Rub = "RUB", + Sar = "SAR", + Sek = "SEK", + Sgd = "SGD", + Thb = "THB", + Try = "TRY", + Twd = "TWD", + Uah = "UAH", + Ves = "VES", + Vnd = "VND", + Zar = "ZAR", +} + +export enum Network { + Testnet = "testnet", + Mainnet = "mainnet" +} + +export enum Blockchain { + Bitcoin = "bitcoin", + Monero = "monero" +} + +const initialState: SettingsState = { + nodes: { + [Network.Testnet]: { + [Blockchain.Bitcoin]: [ + "ssl://blockstream.info:993", + "tcp://blockstream.info:143", + "ssl://testnet.aranguren.org:51002", + "tcp://testnet.aranguren.org:51001", + "ssl://bitcoin.stagemole.eu:5010", + "tcp://bitcoin.stagemole.eu:5000", + ], + [Blockchain.Monero]: [] + }, + [Network.Mainnet]: { + [Blockchain.Bitcoin]: [ + "ssl://electrum.blockstream.info:50002", + "tcp://electrum.blockstream.info:50001", + "ssl://bitcoin.stackwallet.com:50002", + "ssl://b.1209k.com:50002", + "tcp://electrum.coinucopia.io:50001", + ], + [Blockchain.Monero]: [] + } + }, + theme: Theme.Darker, + fetchFiatPrices: false, + fiatCurrency: FiatCurrency.Usd, }; const alertsSlice = createSlice({ name: "settings", initialState, reducers: { - setElectrumRpcUrl(slice, action: PayloadAction<string | null>) { - if (action.payload === null || action.payload === "") { - slice.electrum_rpc_url = null; - } else { - slice.electrum_rpc_url = action.payload; + moveUpNode(slice, action: PayloadAction<{ network: Network, type: Blockchain, node: string }>) { + const index = slice.nodes[action.payload.network][action.payload.type].indexOf(action.payload.node); + if (index > 0) { + const temp = slice.nodes[action.payload.network][action.payload.type][index]; + slice.nodes[action.payload.network][action.payload.type][index] = slice.nodes[action.payload.network][action.payload.type][index - 1]; + slice.nodes[action.payload.network][action.payload.type][index - 1] = temp; } }, - setMoneroNodeUrl(slice, action: PayloadAction<string | null>) { - if (action.payload === null || action.payload === "") { - slice.monero_node_url = null; - } else { - slice.monero_node_url = action.payload; + setTheme(slice, action: PayloadAction<Theme>) { + slice.theme = action.payload; + }, + setFetchFiatPrices(slice, action: PayloadAction<boolean>) { + slice.fetchFiatPrices = action.payload; + }, + setFiatCurrency(slice, action: PayloadAction<FiatCurrency>) { + console.log("setFiatCurrency", action.payload); + slice.fiatCurrency = action.payload; + }, + addNode(slice, action: PayloadAction<{ network: Network, type: Blockchain, node: string }>) { + // Make sure the node is not already in the list + if (slice.nodes[action.payload.network][action.payload.type].includes(action.payload.node)) { + return; } + // Add the node to the list + slice.nodes[action.payload.network][action.payload.type].push(action.payload.node); + }, + removeNode(slice, action: PayloadAction<{ network: Network, type: Blockchain, node: string }>) { + slice.nodes[action.payload.network][action.payload.type] = slice.nodes[action.payload.network][action.payload.type].filter(node => node !== action.payload.node); }, - resetSettings(slice) { + resetSettings(_) { return initialState; } }, }); export const { - setElectrumRpcUrl, - setMoneroNodeUrl, + moveUpNode, + setTheme, + addNode, + removeNode, resetSettings, + setFetchFiatPrices, + setFiatCurrency, } = alertsSlice.actions; export default alertsSlice.reducer; diff --git a/src-gui/src/store/hooks.ts b/src-gui/src/store/hooks.ts index bd77592cb..1764b2d81 100644 --- a/src-gui/src/store/hooks.ts +++ b/src-gui/src/store/hooks.ts @@ -5,7 +5,9 @@ import type { AppDispatch, RootState } from "renderer/store/storeRenderer"; import { parseDateString } from "utils/parseUtils"; import { useMemo } from "react"; import { isCliLogRelatedToSwap } from "models/cliModel"; -import { TauriSettings } from "models/tauriModel"; +import { SettingsState } from "./features/settingsSlice"; +import { NodesSlice } from "./features/nodesSlice"; +import { RatesState } from "./features/ratesSlice"; export const useAppDispatch = () => useDispatch<AppDispatch>(); export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector; @@ -84,6 +86,17 @@ export function useSwapInfosSortedByDate() { ); } -export function useSettings<T>(selector: (settings: TauriSettings) => T): T { - return useAppSelector((state) => selector(state.settings)); -} \ No newline at end of file +export function useRates<T>(selector: (rates: RatesState) => T): T { + const rates = useAppSelector((state) => state.rates); + return selector(rates); +} + +export function useSettings<T>(selector: (settings: SettingsState) => T): T { + const settings = useAppSelector((state) => state.settings); + return selector(settings); +} + +export function useNodes<T>(selector: (nodes: NodesSlice) => T): T { + const nodes = useAppSelector((state) => state.nodes); + return selector(nodes); +} diff --git a/src-gui/src/store/middleware/storeListener.ts b/src-gui/src/store/middleware/storeListener.ts index 5a458aefe..d648ff8cd 100644 --- a/src-gui/src/store/middleware/storeListener.ts +++ b/src-gui/src/store/middleware/storeListener.ts @@ -1,7 +1,10 @@ import { createListenerMiddleware } from "@reduxjs/toolkit"; -import { getAllSwapInfos, checkBitcoinBalance } from "renderer/rpc"; +import { getAllSwapInfos, checkBitcoinBalance, updateAllNodeStatuses } from "renderer/rpc"; import logger from "utils/logger"; import { contextStatusEventReceived } from "store/features/rpcSlice"; +import { addNode, setFetchFiatPrices, setFiatCurrency } from "store/features/settingsSlice"; +import { updateRates } from "renderer/api"; +import { store } from "renderer/store/storeRenderer"; export function createMainListeners() { const listener = createListenerMiddleware(); @@ -24,5 +27,35 @@ export function createMainListeners() { }, }); + // Update the rates when the fiat currency is changed + listener.startListening({ + actionCreator: setFiatCurrency, + effect: async () => { + if (store.getState().settings.fetchFiatPrices) { + console.log("Fiat currency changed, updating rates..."); + await updateRates(); + } + }, + }); + + // Update the rates when fetching fiat prices is enabled + listener.startListening({ + actionCreator: setFetchFiatPrices, + effect: async (action) => { + if (action.payload === true) { + console.log("Activated fetching fiat prices, updating rates..."); + await updateRates(); + } + }, + }); + + // Update the node status when a new one is added + listener.startListening({ + actionCreator: addNode, + effect: async (_) => { + await updateAllNodeStatuses(); + }, + }); + return listener; } diff --git a/src-gui/src/utils/formatUtils.ts b/src-gui/src/utils/formatUtils.ts new file mode 100644 index 000000000..319b66fc9 --- /dev/null +++ b/src-gui/src/utils/formatUtils.ts @@ -0,0 +1,48 @@ +import { FiatCurrency } from "store/features/settingsSlice"; + +/** + * Returns the symbol for a given fiat currency. + * @param currency The fiat currency to get the symbol for. + * @returns The symbol for the given fiat currency, or null if the currency is not supported. + */ +export function currencySymbol(currency: FiatCurrency): string | null { + switch (currency) { + case FiatCurrency.Usd: return "$"; + case FiatCurrency.Eur: return "€"; + case FiatCurrency.Gbp: return "£"; + case FiatCurrency.Chf: return "CHF"; + case FiatCurrency.Jpy: return "¥"; + case FiatCurrency.Ars: return "$"; + case FiatCurrency.Aud: return "$"; + case FiatCurrency.Cad: return "$"; + case FiatCurrency.Cny: return "¥"; + case FiatCurrency.Czk: return "Kč"; + case FiatCurrency.Dkk: return "DKK"; + case FiatCurrency.Gel: return "₾"; + case FiatCurrency.Hkd: return "HK$"; + case FiatCurrency.Ils: return "₪"; + case FiatCurrency.Inr: return "₹"; + case FiatCurrency.Krw: return "₩"; + case FiatCurrency.Kwd: return "KD"; + case FiatCurrency.Lkr: return "₨"; + case FiatCurrency.Mmk: return "K"; + case FiatCurrency.Mxn: return "$"; + case FiatCurrency.Nok: return "NOK"; + case FiatCurrency.Nzd: return "$"; + case FiatCurrency.Php: return "₱"; + case FiatCurrency.Pkr: return "₨"; + case FiatCurrency.Pln: return "zł"; + case FiatCurrency.Rub: return "₽"; + case FiatCurrency.Sar: return "SR"; + case FiatCurrency.Sek: return "SEK"; + case FiatCurrency.Sgd: return "$"; + case FiatCurrency.Thb: return "฿"; + case FiatCurrency.Try: return "₺"; + case FiatCurrency.Twd: return "NT$"; + case FiatCurrency.Uah: return "₴"; + case FiatCurrency.Ves: return "Bs"; + case FiatCurrency.Vnd: return "₫"; + case FiatCurrency.Zar: return "R "; + default: return null; + } +} \ No newline at end of file diff --git a/src-gui/yarn.lock b/src-gui/yarn.lock index b7e964720..c7e65d16c 100644 --- a/src-gui/yarn.lock +++ b/src-gui/yarn.lock @@ -902,7 +902,7 @@ dependencies: "@tauri-apps/api" "^2.0.0" -"@tauri-apps/plugin-store@2.1.0": +"@tauri-apps/plugin-store@^2.1.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-store/-/plugin-store-2.1.0.tgz#02d58e068e52c314417a7df34f3c39eb2b151aa8" integrity sha512-GADqrc17opUKYIAKnGHIUgEeTZ2wJGu1ZITKQ1WMuOFdv8fvXRFBAqsqPjE3opgWohbczX6e1NpwmZK1AnuWVw== diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 84129b6fd..e1df866f2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,10 +4,11 @@ use std::sync::Arc; use swap::cli::{ api::{ request::{ - BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, ExportBitcoinWalletArgs, GetHistoryArgs, - GetLogsArgs, GetMoneroAddressesArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, - ListSellersArgs, MoneroRecoveryArgs, ResumeSwapArgs, SuspendCurrentSwapArgs, - WithdrawBtcArgs, + BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, CheckElectrumNodeArgs, + CheckElectrumNodeResponse, CheckMoneroNodeArgs, CheckMoneroNodeResponse, + ExportBitcoinWalletArgs, GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, + GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, + ResumeSwapArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs, }, tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings}, Context, ContextBuilder, @@ -161,6 +162,8 @@ pub fn run() { cancel_and_refund, is_context_available, initialize_context, + check_monero_node, + check_electrum_node, get_wallet_descriptor, ]) .setup(setup) @@ -202,7 +205,6 @@ tauri_command!(monero_recovery, MoneroRecoveryArgs); tauri_command!(get_logs, GetLogsArgs); tauri_command!(list_sellers, ListSellersArgs); tauri_command!(cancel_and_refund, CancelAndRefundArgs); - // These commands require no arguments tauri_command!(get_wallet_descriptor, ExportBitcoinWalletArgs, no_args); tauri_command!(suspend_current_swap, SuspendCurrentSwapArgs, no_args); @@ -218,6 +220,22 @@ async fn is_context_available(context: tauri::State<'_, RwLock<State>>) -> Resul Ok(context.read().await.try_get_context().is_ok()) } +#[tauri::command] +async fn check_monero_node( + args: CheckMoneroNodeArgs, + _: tauri::State<'_, RwLock<State>>, +) -> Result<CheckMoneroNodeResponse, String> { + args.request().await.to_string_result() +} + +#[tauri::command] +async fn check_electrum_node( + args: CheckElectrumNodeArgs, + _: tauri::State<'_, RwLock<State>>, +) -> Result<CheckElectrumNodeResponse, String> { + args.request().await.to_string_result() +} + /// Tauri command to initialize the Context #[tauri::command] async fn initialize_context( diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8562ead3a..d20519731 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { - "productName": "UnstoppableSwap (new)", - "version": "0.7.2", + "productName": "UnstoppableSwap", + "version": "1.0.0-alpha.1", "identifier": "net.unstoppableswap.gui", "build": { "devUrl": "http://localhost:1420", @@ -13,7 +13,7 @@ "app": { "windows": [ { - "title": "unstoppableswap-gui-rs", + "title": "UnstoppableSwap", "width": 800, "height": 600 } diff --git a/swap/src/bitcoin/wallet.rs b/swap/src/bitcoin/wallet.rs index f79bd5f9f..d118f8250 100644 --- a/swap/src/bitcoin/wallet.rs +++ b/swap/src/bitcoin/wallet.rs @@ -69,7 +69,7 @@ impl Wallet { err => err?, }; - let client = Client::new(electrum_rpc_url, env_config.bitcoin_sync_interval())?; + let client = Client::new(electrum_rpc_url, env_config.bitcoin_sync_interval(), 5)?; let network = wallet.network(); @@ -722,9 +722,9 @@ pub struct Client { } impl Client { - fn new(electrum_rpc_url: Url, interval: Duration) -> Result<Self> { + pub fn new(electrum_rpc_url: Url, interval: Duration, retry_count: u8) -> Result<Self> { let config = bdk::electrum_client::ConfigBuilder::default() - .retry(5) + .retry(retry_count) .build(); let electrum = bdk::electrum_client::Client::from_config(electrum_rpc_url.as_str(), config) diff --git a/swap/src/cli/api.rs b/swap/src/cli/api.rs index 15b0565e5..91d78502e 100644 --- a/swap/src/cli/api.rs +++ b/swap/src/cli/api.rs @@ -324,15 +324,6 @@ impl ContextBuilder { ) .await?; - // If we are connected to the Bitcoin blockchain and if there is a handle to Tauri present, - // we start a background task to watch for timelock changes. - if let Some(wallet) = bitcoin_wallet.clone() { - if self.tauri_handle.is_some() { - let watcher = Watcher::new(wallet, db.clone(), self.tauri_handle.clone()); - tokio::spawn(watcher.run()); - } - } - // We initialize the Monero wallet below // To display the progress to the user, we emit events to the Tauri frontend self.tauri_handle @@ -360,6 +351,15 @@ impl ContextBuilder { let tor_socks5_port = self.tor.map_or(9050, |tor| tor.tor_socks5_port); + // If we are connected to the Bitcoin blockchain and if there is a handle to Tauri present, + // we start a background task to watch for timelock changes. + if let Some(wallet) = bitcoin_wallet.clone() { + if self.tauri_handle.is_some() { + let watcher = Watcher::new(wallet, db.clone(), self.tauri_handle.clone()); + tokio::spawn(watcher.run()); + } + } + let context = Context { db, bitcoin_wallet, @@ -473,6 +473,11 @@ async fn init_monero_wallet( let monero_wallet_rpc = monero::WalletRpc::new(data_dir.join("monero")).await?; + tracing::debug!( + address = monero_daemon_address, + "Attempting to start monero-wallet-rpc process" + ); + let monero_wallet_rpc_process = monero_wallet_rpc .run(network, Some(monero_daemon_address)) .await diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index 798aa6afa..f5e6e5fb0 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -1,19 +1,22 @@ use super::tauri_bindings::TauriHandle; -use crate::bitcoin::{CancelTimelock, ExpiredTimelocks, PunishTimelock, TxLock}; +use crate::bitcoin::{wallet, CancelTimelock, ExpiredTimelocks, PunishTimelock, TxLock}; use crate::cli::api::tauri_bindings::{TauriEmitter, TauriSwapProgressEvent}; use crate::cli::api::Context; use crate::cli::{list_sellers as list_sellers_impl, EventLoop, Seller, SellerStatus}; use crate::common::get_logs; use crate::libp2p_ext::MultiAddrExt; +use crate::monero::wallet_rpc::MoneroDaemon; use crate::network::quote::{BidQuote, ZeroQuoteReceived}; use crate::network::swarm; use crate::protocol::bob::{BobState, Swap}; use crate::protocol::{bob, State}; use crate::{bitcoin, cli, monero, rpc}; use ::bitcoin::Txid; +use ::monero::Network; use anyhow::{bail, Context as AnyContext, Result}; use libp2p::core::Multiaddr; use libp2p::PeerId; +use once_cell::sync::Lazy; use qrcode::render::unicode; use qrcode::QrCode; use serde::{Deserialize, Serialize}; @@ -25,6 +28,7 @@ use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use thiserror::Error; use tracing::debug_span; use tracing::Instrument; use tracing::Span; @@ -1294,3 +1298,77 @@ where Ok((btc_swap_amount, fees)) } + +#[typeshare] +#[derive(Deserialize, Serialize)] +pub struct CheckMoneroNodeArgs { + pub url: String, + pub network: String, +} + +#[typeshare] +#[derive(Deserialize, Serialize)] +pub struct CheckMoneroNodeResponse { + pub available: bool, +} + +#[derive(Error, Debug)] +#[error("this is not one of the known monero networks")] +struct UnknownMoneroNetwork(String); + +impl CheckMoneroNodeArgs { + pub async fn request(self) -> Result<CheckMoneroNodeResponse> { + let network = match self.network.to_lowercase().as_str() { + // When the GUI says testnet, it means monero stagenet + "mainnet" => Network::Mainnet, + "testnet" => Network::Stagenet, + otherwise => anyhow::bail!(UnknownMoneroNetwork(otherwise.to_string())), + }; + + static CLIENT: Lazy<reqwest::Client> = Lazy::new(|| { + reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .https_only(false) + .build() + .expect("reqwest client to work") + }); + + let Ok(monero_daemon) = MoneroDaemon::from_str(self.url, network) else { + return Ok(CheckMoneroNodeResponse { available: false }); + }; + + let Ok(available) = monero_daemon.is_available(&CLIENT).await else { + return Ok(CheckMoneroNodeResponse { available: false }); + }; + + Ok(CheckMoneroNodeResponse { available }) + } +} + +#[typeshare] +#[derive(Deserialize, Clone)] +pub struct CheckElectrumNodeArgs { + pub url: String, +} + +#[typeshare] +#[derive(Serialize, Clone)] +pub struct CheckElectrumNodeResponse { + pub available: bool, +} + +impl CheckElectrumNodeArgs { + pub async fn request(self) -> Result<CheckElectrumNodeResponse> { + // Check if the URL is valid + let Ok(url) = self.url.parse() else { + return Ok(CheckElectrumNodeResponse { available: false }); + }; + + // Check if the node is available + let res = wallet::Client::new(url, Duration::from_secs(10), 0); + + Ok(CheckElectrumNodeResponse { + available: res.is_ok(), + }) + } +} diff --git a/swap/src/monero.rs b/swap/src/monero.rs index c02bf5cb2..f23254ff9 100644 --- a/swap/src/monero.rs +++ b/swap/src/monero.rs @@ -1,5 +1,5 @@ pub mod wallet; -mod wallet_rpc; +pub mod wallet_rpc; pub use ::monero::network::Network; pub use ::monero::{Address, PrivateKey, PublicKey}; diff --git a/swap/src/monero/wallet_rpc.rs b/swap/src/monero/wallet_rpc.rs index 561530b15..2f23d4022 100644 --- a/swap/src/monero/wallet_rpc.rs +++ b/swap/src/monero/wallet_rpc.rs @@ -4,6 +4,7 @@ use big_bytes::BigByte; use data_encoding::HEXLOWER; use futures::{StreamExt, TryStreamExt}; use monero_rpc::wallet::{Client, MoneroWalletRpc as _}; +use once_cell::sync::Lazy; use reqwest::header::CONTENT_LENGTH; use reqwest::Url; use serde::Deserialize; @@ -22,24 +23,26 @@ use tokio_util::io::StreamReader; // See: https://www.moneroworld.com/#nodes, https://monero.fail // We don't need any testnet nodes because we don't support testnet at all -const MONERO_DAEMONS: [MoneroDaemon; 16] = [ - MoneroDaemon::new("xmr-node.cakewallet.com", 18081, Network::Mainnet), - MoneroDaemon::new("nodex.monerujo.io", 18081, Network::Mainnet), - MoneroDaemon::new("nodes.hashvault.pro", 18081, Network::Mainnet), - MoneroDaemon::new("p2pmd.xmrvsbeast.com", 18081, Network::Mainnet), - MoneroDaemon::new("node.monerodevs.org", 18089, Network::Mainnet), - MoneroDaemon::new("xmr-node-usa-east.cakewallet.com", 18081, Network::Mainnet), - MoneroDaemon::new("xmr-node-uk.cakewallet.com", 18081, Network::Mainnet), - MoneroDaemon::new("node.community.rino.io", 18081, Network::Mainnet), - MoneroDaemon::new("testingjohnross.com", 20031, Network::Mainnet), - MoneroDaemon::new("xmr.litepay.ch", 18081, Network::Mainnet), - MoneroDaemon::new("node.trocador.app", 18089, Network::Mainnet), - MoneroDaemon::new("stagenet.xmr-tw.org", 38081, Network::Stagenet), - MoneroDaemon::new("node.monerodevs.org", 38089, Network::Stagenet), - MoneroDaemon::new("singapore.node.xmr.pm", 38081, Network::Stagenet), - MoneroDaemon::new("xmr-lux.boldsuck.org", 38081, Network::Stagenet), - MoneroDaemon::new("stagenet.community.rino.io", 38081, Network::Stagenet), -]; +const MONERO_DAEMONS: Lazy<[MoneroDaemon; 16]> = Lazy::new(|| { + [ + MoneroDaemon::new("xmr-node.cakewallet.com", 18081, Network::Mainnet), + MoneroDaemon::new("nodex.monerujo.io", 18081, Network::Mainnet), + MoneroDaemon::new("nodes.hashvault.pro", 18081, Network::Mainnet), + MoneroDaemon::new("p2pmd.xmrvsbeast.com", 18081, Network::Mainnet), + MoneroDaemon::new("node.monerodevs.org", 18089, Network::Mainnet), + MoneroDaemon::new("xmr-node-usa-east.cakewallet.com", 18081, Network::Mainnet), + MoneroDaemon::new("xmr-node-uk.cakewallet.com", 18081, Network::Mainnet), + MoneroDaemon::new("node.community.rino.io", 18081, Network::Mainnet), + MoneroDaemon::new("testingjohnross.com", 20031, Network::Mainnet), + MoneroDaemon::new("xmr.litepay.ch", 18081, Network::Mainnet), + MoneroDaemon::new("node.trocador.app", 18089, Network::Mainnet), + MoneroDaemon::new("stagenet.xmr-tw.org", 38081, Network::Stagenet), + MoneroDaemon::new("node.monerodevs.org", 38089, Network::Stagenet), + MoneroDaemon::new("singapore.node.xmr.pm", 38081, Network::Stagenet), + MoneroDaemon::new("xmr-lux.boldsuck.org", 38081, Network::Stagenet), + MoneroDaemon::new("stagenet.community.rino.io", 38081, Network::Stagenet), + ] +}); #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] compile_error!("unsupported operating system"); @@ -87,26 +90,26 @@ pub struct WalletRpcProcess { port: u16, } -#[derive(Debug, Copy, Clone)] -struct MoneroDaemon { - address: &'static str, +#[derive(Debug, Clone)] +pub struct MoneroDaemon { + address: String, port: u16, network: Network, } impl MoneroDaemon { - const fn new(address: &'static str, port: u16, network: Network) -> Self { - Self { - address, + pub fn new(address: impl Into<String>, port: u16, network: Network) -> MoneroDaemon { + MoneroDaemon { + address: address.into(), port, network, } } - pub fn from_str(address: String, network: Network) -> Result<Self, Error> { - let (address, port) = extract_host_and_port(address)?; + pub fn from_str(address: impl Into<String>, network: Network) -> Result<MoneroDaemon, Error> { + let (address, port) = extract_host_and_port(address.into())?; - Ok(Self { + Ok(MoneroDaemon { address, port, network, @@ -114,7 +117,7 @@ impl MoneroDaemon { } /// Checks if the Monero daemon is available by sending a request to its `get_info` endpoint. - async fn is_available(&self, client: &reqwest::Client) -> Result<bool, Error> { + pub async fn is_available(&self, client: &reqwest::Client) -> Result<bool, Error> { let url = format!("http://{}:{}/get_info", self.address, self.port); let res = client .get(url) @@ -162,15 +165,14 @@ async fn choose_monero_daemon(network: Network) -> Result<MoneroDaemon, Error> { .build()?; // We only want to check for daemons that match the specified network - let network_matching_daemons = MONERO_DAEMONS - .iter() - .filter(|daemon| daemon.network == network); + let daemons = &*MONERO_DAEMONS; + let network_matching_daemons = daemons.iter().filter(|daemon| daemon.network == network); for daemon in network_matching_daemons { match daemon.is_available(&client).await { Ok(true) => { tracing::debug!(%daemon, "Found available Monero daemon"); - return Ok(*daemon); + return Ok(daemon.clone()); } Err(err) => { tracing::debug!(%err, %daemon, "Failed to connect to Monero daemon"); @@ -402,7 +404,6 @@ impl WalletRpc { line?; } - // Send a json rpc request to make sure monero_wallet_rpc is ready Client::localhost(port)?.get_version().await?; Ok(WalletRpcProcess { @@ -486,7 +487,7 @@ impl WalletRpc { } } -fn extract_host_and_port(address: String) -> Result<(&'static str, u16), Error> { +fn extract_host_and_port(address: String) -> Result<(String, u16), Error> { // Strip the protocol (anything before "://") let stripped_address = if let Some(pos) = address.find("://") { address[(pos + 3)..].to_string() @@ -501,9 +502,7 @@ fn extract_host_and_port(address: String) -> Result<(&'static str, u16), Error> let host = parts[0].to_string(); let port = parts[1].parse::<u16>()?; - // Leak the host string to create a 'static lifetime string - let static_str_host: &'static str = Box::leak(host.into_boxed_str()); - return Ok((static_str_host, port)); + return Ok((host, port)); } bail!(