feat(GUI): Add settings for theme, fiat currency and remote nodes (#128)

This commit is contained in:
Einliterflasche 2024-11-13 22:51:47 +01:00 committed by GitHub
parent 27d6e23b93
commit 3e79bb3712
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1133 additions and 267 deletions

2
src-gui/.gitignore vendored
View file

@ -28,4 +28,4 @@ dist-ssr
src/models/tauriModel.ts src/models/tauriModel.ts
# Env files # Env files
.env.* .env*

View file

@ -24,7 +24,7 @@
"@tauri-apps/plugin-clipboard-manager": "^2.0.0", "@tauri-apps/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0", "@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-shell": "^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", "@tauri-apps/plugin-updater": "^2.0.0",
"humanize-duration": "^3.32.1", "humanize-duration": "^3.32.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",

View file

@ -5,6 +5,9 @@
// - and to submit feedback // - and to submit feedback
// - fetch currency rates from CoinGecko // - fetch currency rates from CoinGecko
import { Alert, ExtendedProviderStatus } from "models/apiModel"; 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"; const API_BASE_URL = "https://api.unstoppableswap.net";
@ -45,20 +48,20 @@ export async function submitFeedbackViaHttp(
return responseBody.feedbackId; return responseBody.feedbackId;
} }
async function fetchCurrencyUsdPrice(currency: string): Promise<number> { async function fetchCurrencyPrice(currency: string, fiatCurrency: FiatCurrency): Promise<number> {
try { try {
const response = await fetch( 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(); const data = await response.json();
return data[currency].usd; return data[currency][fiatCurrency.toLowerCase()];
} catch (error) { } catch (error) {
console.error(`Error fetching ${currency} price:`, error); console.error(`Error fetching ${currency} price:`, error);
throw error; throw error;
} }
} }
export async function fetchXmrBtcRate(): Promise<number> { async function fetchXmrBtcRate(): Promise<number> {
try { try {
const response = await fetch('https://api.kraken.com/0/public/Ticker?pair=XMRXBT'); const response = await fetch('https://api.kraken.com/0/public/Ticker?pair=XMRXBT');
const data = await response.json(); const data = await response.json();
@ -78,10 +81,35 @@ export async function fetchXmrBtcRate(): Promise<number> {
} }
export async function fetchBtcPrice(): Promise<number> { async function fetchBtcPrice(fiatCurrency: FiatCurrency): Promise<number> {
return fetchCurrencyUsdPrice("bitcoin"); return fetchCurrencyPrice("bitcoin", fiatCurrency);
} }
export async function fetchXmrPrice(): Promise<number> { async function fetchXmrPrice(fiatCurrency: FiatCurrency): Promise<number> {
return fetchCurrencyUsdPrice("monero"); 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<void> {
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);
}
} }

View file

@ -1,6 +1,5 @@
import { Box, CssBaseline, makeStyles } from "@material-ui/core"; import { Box, CssBaseline, makeStyles } from "@material-ui/core";
import { indigo } from "@material-ui/core/colors"; import { ThemeProvider } from "@material-ui/core/styles";
import { createTheme, ThemeProvider } from "@material-ui/core/styles";
import "@tauri-apps/plugin-shell"; import "@tauri-apps/plugin-shell";
import { Route, MemoryRouter as Router, Routes } from "react-router-dom"; import { Route, MemoryRouter as Router, Routes } from "react-router-dom";
import Navigation, { drawerWidth } from "./navigation/Navigation"; import Navigation, { drawerWidth } from "./navigation/Navigation";
@ -9,15 +8,17 @@ import HistoryPage from "./pages/history/HistoryPage";
import SwapPage from "./pages/swap/SwapPage"; import SwapPage from "./pages/swap/SwapPage";
import WalletPage from "./pages/wallet/WalletPage"; import WalletPage from "./pages/wallet/WalletPage";
import GlobalSnackbarProvider from "./snackbar/GlobalSnackbarProvider"; 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 UpdaterDialog from "./modal/updater/UpdaterDialog";
import { setAlerts } from "store/features/alertsSlice"; import { useSettings } from "store/hooks";
import { setRegistryProviders, registryConnectionFailed } from "store/features/providersSlice"; import { themes } from "./theme";
import { setXmrPrice, setBtcPrice, setXmrBtcRate } from "store/features/ratesSlice"; 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 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) => ({ const useStyles = makeStyles((theme) => ({
innerContent: { innerContent: {
@ -28,20 +29,27 @@ const useStyles = makeStyles((theme) => ({
}, },
})); }));
const theme = createTheme({ export default function App() {
palette: { useEffect(() => {
type: "dark", fetchInitialData();
primary: { initEventListeners();
main: "#f4511e", }, []);
},
secondary: indigo, const theme = useSettings((s) => s.theme);
},
typography: { return (
overline: { <ThemeProvider theme={themes[theme]}>
textTransform: "none", // This prevents the text from being all caps <GlobalSnackbarProvider>
}, <CssBaseline />
}, <Router>
}); <Navigation />
<InnerContent />
<UpdaterDialog />
</Router>
</GlobalSnackbarProvider>
</ThemeProvider>
);
}
function InnerContent() { function InnerContent() {
const classes = useStyles(); const classes = useStyles();
@ -59,26 +67,6 @@ function InnerContent() {
); );
} }
export default function App() {
useEffect(() => {
fetchInitialData();
initEventListeners();
}, []);
return (
<ThemeProvider theme={theme}>
<GlobalSnackbarProvider>
<CssBaseline />
<Router>
<Navigation />
<InnerContent />
<UpdaterDialog/>
</Router>
</GlobalSnackbarProvider>
</ThemeProvider>
);
}
async function fetchInitialData() { async function fetchInitialData() {
try { try {
const providerList = await fetchProvidersViaHttp(); const providerList = await fetchProvidersViaHttp();
@ -93,6 +81,16 @@ async function fetchInitialData() {
logger.error(e, "Failed to fetch providers via UnstoppableSwap HTTP API"); logger.error(e, "Failed to fetch providers via UnstoppableSwap HTTP API");
} }
try {
await updateAllNodeStatuses()
} catch (e) {
logger.error(e, "Failed to update node statuses")
}
// Update node statuses every 2 minutes
const STATUS_UPDATE_INTERVAL = 2 * 60 * 1_000;
setInterval(updateAllNodeStatuses, STATUS_UPDATE_INTERVAL);
try { try {
const alerts = await fetchAlertsViaHttp(); const alerts = await fetchAlertsViaHttp();
store.dispatch(setAlerts(alerts)); store.dispatch(setAlerts(alerts));
@ -102,22 +100,13 @@ async function fetchInitialData() {
} }
try { try {
const xmrPrice = await fetchXmrPrice(); await updateRates();
store.dispatch(setXmrPrice(xmrPrice)); logger.info("Fetched XMR/BTC rate");
logger.info({ xmrPrice }, "Fetched XMR price");
const btcPrice = await fetchBtcPrice();
store.dispatch(setBtcPrice(btcPrice));
logger.info({ btcPrice }, "Fetched BTC price");
} catch (e) {
logger.error(e, "Error retrieving fiat prices");
}
try {
const xmrBtcRate = await fetchXmrBtcRate();
store.dispatch(setXmrBtcRate(xmrBtcRate));
logger.info({ xmrBtcRate }, "Fetched XMR/BTC rate");
} catch (e) { } catch (e) {
logger.error(e, "Error retrieving XMR/BTC rate"); 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);
} }

View file

@ -50,7 +50,7 @@ export default function DaemonStatusAlert() {
<Button <Button
size="small" size="small"
variant="outlined" variant="outlined"
onClick={() => navigate("/help")} onClick={() => navigate("/help#daemon-control-box")}
> >
View Logs View Logs
</Button> </Button>

View file

@ -26,19 +26,21 @@ const useStyles = makeStyles((theme) => ({
}, },
})); }));
function ProviderSpreadChip({ provider }: { provider: ExtendedProviderStatus }) { /**
const xmrBtcPrice = useAppSelector(s => s.rates?.xmrBtcRate); * A chip that displays the markup of the provider's exchange rate compared to the market rate.
*/
if (xmrBtcPrice === null) { function ProviderMarkupChip({ provider }: { provider: ExtendedProviderStatus }) {
const marketExchangeRate = useAppSelector(s => s.rates?.xmrBtcRate);
if (marketExchangeRate === null)
return null; return null;
}
const providerPrice = satsToBtc(provider.price); const providerExchangeRate = satsToBtc(provider.price);
const spread = ((providerPrice - xmrBtcPrice) / xmrBtcPrice) * 100; /** The markup of the exchange rate compared to the market rate in percent */
const markup = (providerExchangeRate - marketExchangeRate) / marketExchangeRate * 100;
return ( return (
<Tooltip title="The spread is the difference between the provider's exchange rate and the market rate. A high spread indicates that the provider is charging more than the market rate."> <Tooltip title="The markup this provider charges compared to centralized markets. A lower markup means that you get more Monero for your Bitcoin.">
<Chip label={`Spread: ${spread.toFixed(2)} %`} /> <Chip label={`Markup ${markup.toFixed(2)}%`} />
</Tooltip> </Tooltip>
); );
@ -74,8 +76,8 @@ export default function ProviderInfo({
<Box className={classes.chipsOuter}> <Box className={classes.chipsOuter}>
<Chip label={provider.testnet ? "Testnet" : "Mainnet"} /> <Chip label={provider.testnet ? "Testnet" : "Mainnet"} />
{provider.uptime && ( {provider.uptime && (
<Tooltip title="A high uptime indicates reliability. Providers with low uptime may be unreliable and cause swaps to take longer to complete or fail entirely."> <Tooltip title="A high uptime (>90%) indicates reliability. Providers with very low uptime may be unreliable and cause swaps to take longer to complete or fail entirely.">
<Chip label={`${Math.round(provider.uptime * 100)} % uptime`} /> <Chip label={`${Math.round(provider.uptime * 100)}% uptime`} />
</Tooltip> </Tooltip>
)} )}
{provider.age ? ( {provider.age ? (
@ -93,11 +95,11 @@ export default function ProviderInfo({
</Tooltip> </Tooltip>
)} )}
{isOutdated && ( {isOutdated && (
<Tooltip title="This provider is running an outdated version of the software. Outdated providers may be unreliable and cause swaps to take longer to complete or fail entirely."> <Tooltip title="This provider is running an older version of the software. Outdated providers may be unreliable and cause swaps to take longer to complete or fail entirely.">
<Chip label="Outdated" icon={<WarningIcon />} color="primary" /> <Chip label="Outdated" icon={<WarningIcon />} color="primary" />
</Tooltip> </Tooltip>
)} )}
<ProviderSpreadChip provider={provider} /> <ProviderMarkupChip provider={provider} />
</Box> </Box>
</Box> </Box>
); );

View file

@ -8,6 +8,7 @@ import {
import { ReactNode } from "react"; import { ReactNode } from "react";
type Props = { type Props = {
id?: string;
title: ReactNode; title: ReactNode;
mainContent: ReactNode; mainContent: ReactNode;
additionalContent: ReactNode; additionalContent: ReactNode;
@ -31,6 +32,7 @@ const useStyles = makeStyles((theme) => ({
})); }));
export default function InfoBox({ export default function InfoBox({
id = null,
title, title,
mainContent, mainContent,
additionalContent, additionalContent,
@ -40,7 +42,7 @@ export default function InfoBox({
const classes = useStyles(); const classes = useStyles();
return ( return (
<Paper variant="outlined" className={classes.outer}> <Paper variant="outlined" className={classes.outer} id={id}>
<Typography variant="subtitle1">{title}</Typography> <Typography variant="subtitle1">{title}</Typography>
<Box className={classes.upperContent}> <Box className={classes.upperContent}>
{icon} {icon}

View file

@ -21,7 +21,7 @@ export default function NavigationHeader() {
<RouteListItemIconButton name="Wallet" route="/wallet"> <RouteListItemIconButton name="Wallet" route="/wallet">
<AccountBalanceWalletIcon /> <AccountBalanceWalletIcon />
</RouteListItemIconButton> </RouteListItemIconButton>
<RouteListItemIconButton name="Help" route="/help"> <RouteListItemIconButton name="Help & Settings" route="/help">
<HelpOutlineIcon /> <HelpOutlineIcon />
</RouteListItemIconButton> </RouteListItemIconButton>
</List> </List>

View file

@ -8,16 +8,18 @@ export function AmountWithUnit({
amount, amount,
unit, unit,
fixedPrecision, fixedPrecision,
dollarRate, exchangeRate,
}: { }: {
amount: Amount; amount: Amount;
unit: string; unit: string;
fixedPrecision: number; fixedPrecision: number;
dollarRate?: Amount; exchangeRate?: Amount;
}) { }) {
const fetchFiatPrices = useAppSelector((state) => state.settings.fetchFiatPrices);
const fiatCurrency = useAppSelector((state) => state.settings.fiatCurrency);
const title = const title =
dollarRate != null && amount != null fetchFiatPrices && exchangeRate != null && amount != null && fiatCurrency != null
? `$${(dollarRate * amount).toFixed(2)}` ? `${(exchangeRate * amount).toFixed(2)} ${fiatCurrency}`
: ""; : "";
return ( return (
@ -33,31 +35,31 @@ export function AmountWithUnit({
} }
AmountWithUnit.defaultProps = { AmountWithUnit.defaultProps = {
dollarRate: null, exchangeRate: null,
}; };
export function BitcoinAmount({ amount }: { amount: Amount }) { export function BitcoinAmount({ amount }: { amount: Amount }) {
const btcUsdRate = useAppSelector((state) => state.rates.btcPrice); const btcRate = useAppSelector((state) => state.rates.btcPrice);
return ( return (
<AmountWithUnit <AmountWithUnit
amount={amount} amount={amount}
unit="BTC" unit="BTC"
fixedPrecision={6} fixedPrecision={6}
dollarRate={btcUsdRate} exchangeRate={btcRate}
/> />
); );
} }
export function MoneroAmount({ amount }: { amount: Amount }) { export function MoneroAmount({ amount }: { amount: Amount }) {
const xmrUsdRate = useAppSelector((state) => state.rates.xmrPrice); const xmrRate = useAppSelector((state) => state.rates.xmrPrice);
return ( return (
<AmountWithUnit <AmountWithUnit
amount={amount} amount={amount}
unit="XMR" unit="XMR"
fixedPrecision={4} fixedPrecision={4}
dollarRate={xmrUsdRate} exchangeRate={xmrRate}
/> />
); );
} }

View file

@ -6,6 +6,7 @@ interface ValidatedTextFieldProps extends Omit<TextFieldProps, "onChange" | "val
isValid: (value: string) => boolean; isValid: (value: string) => boolean;
onValidatedChange: (value: string | null) => void; onValidatedChange: (value: string | null) => void;
allowEmpty?: boolean; allowEmpty?: boolean;
noErrorWhenEmpty?: boolean;
helperText?: string; helperText?: string;
} }
@ -17,6 +18,7 @@ export default function ValidatedTextField({
helperText = "Invalid input", helperText = "Invalid input",
variant = "standard", variant = "standard",
allowEmpty = false, allowEmpty = false,
noErrorWhenEmpty = false,
...props ...props
}: ValidatedTextFieldProps) { }: ValidatedTextFieldProps) {
const [inputValue, setInputValue] = useState(value || ""); const [inputValue, setInputValue] = useState(value || "");
@ -39,7 +41,7 @@ export default function ValidatedTextField({
setInputValue(value || ""); setInputValue(value || "");
}, [value]); }, [value]);
const isError = allowEmpty && inputValue === "" ? false : !isValid(inputValue); const isError = allowEmpty && inputValue === "" || inputValue === "" && noErrorWhenEmpty ? false : !isValid(inputValue);
return ( return (
<TextField <TextField

View file

@ -33,6 +33,7 @@ export default function DaemonControlBox() {
return ( return (
<InfoBox <InfoBox
id="daemon-control-box"
title={`Daemon Controller (${stringifiedDaemonStatus})`} title={`Daemon Controller (${stringifiedDaemonStatus})`}
mainContent={ mainContent={
<CliLogsBox <CliLogsBox

View file

@ -1,4 +1,4 @@
import { Typography } from "@material-ui/core"; import { Link, Typography } from "@material-ui/core";
import MoneroIcon from "../../icons/MoneroIcon"; import MoneroIcon from "../../icons/MoneroIcon";
import DepositAddressInfoBox from "../../modal/swap/DepositAddressInfoBox"; import DepositAddressInfoBox from "../../modal/swap/DepositAddressInfoBox";
@ -13,11 +13,13 @@ export default function DonateInfoBox() {
icon={<MoneroIcon />} icon={<MoneroIcon />}
additionalContent={ additionalContent={
<Typography variant="subtitle2"> <Typography variant="subtitle2">
We rely on generous donors like you to keep development moving <p>
forward. To bring Atomic Swaps to life, we need resources. If you have As part of the Monero Community Crowdfunding System (CCS), we received funding for 6 months of full-time development by
the possibility, please consider making a donation to the project. All generous donors from the Monero community (<Link href="https://ccs.getmonero.org/proposals/mature-atomic-swaps-ecosystem.html" target="_blank">link</Link>).
funds will be used to support contributors and critical </p>
infrastructure. <p>
If you want to support our effort event further, you can do so at this address.
</p>
</Typography> </Typography>
} }
/> />

View file

@ -5,6 +5,8 @@ import FeedbackInfoBox from "./FeedbackInfoBox";
import DaemonControlBox from "./DaemonControlBox"; import DaemonControlBox from "./DaemonControlBox";
import SettingsBox from "./SettingsBox"; import SettingsBox from "./SettingsBox";
import ExportDataBox from "./ExportDataBox"; import ExportDataBox from "./ExportDataBox";
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
outer: { outer: {
display: "flex", display: "flex",
@ -16,13 +18,21 @@ const useStyles = makeStyles((theme) => ({
export default function HelpPage() { export default function HelpPage() {
const classes = useStyles(); const classes = useStyles();
const location = useLocation();
useEffect(() => {
if (location.hash) {
const element = document.getElementById(location.hash.slice(1));
element?.scrollIntoView({ behavior: "smooth" });
}
}, [location]);
return ( return (
<Box className={classes.outer}> <Box className={classes.outer}>
<FeedbackInfoBox /> <FeedbackInfoBox />
<DaemonControlBox />
<SettingsBox /> <SettingsBox />
<ExportDataBox /> <ExportDataBox />
<DaemonControlBox />
<ContactInfoBox /> <ContactInfoBox />
<DonateInfoBox /> <DonateInfoBox />
</Box> </Box>

View file

@ -9,19 +9,41 @@ import {
Box, Box,
makeStyles, makeStyles,
Tooltip, Tooltip,
Select,
MenuItem,
TableHead,
Paper,
Button,
Dialog,
DialogContent,
DialogActions,
DialogTitle,
useTheme,
Switch, Switch,
} from "@material-ui/core"; } from "@material-ui/core";
import InfoBox from "renderer/components/modal/swap/InfoBox"; import InfoBox from "renderer/components/modal/swap/InfoBox";
import { import {
removeNode,
resetSettings, resetSettings,
setElectrumRpcUrl, setFetchFiatPrices,
setMoneroNodeUrl, setFiatCurrency,
} from "store/features/settingsSlice"; } 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 ValidatedTextField from "renderer/components/other/ValidatedTextField";
import RefreshIcon from "@material-ui/icons/Refresh";
import HelpIcon from '@material-ui/icons/HelpOutline'; 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_ELECTRUM_RPC_URL = "ssl://blockstream.info:700";
const PLACEHOLDER_MONERO_NODE_URL = "http://xmr-node.cakewallet.com:18081"; 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() { export default function SettingsBox() {
const dispatch = useAppDispatch();
const classes = useStyles(); const classes = useStyles();
const theme = useTheme();
return ( return (
<InfoBox <InfoBox
title={ title={
<Box className={classes.title}> <Box className={classes.title}>
Settings Settings
<IconButton
size="small"
onClick={() => {
dispatch(resetSettings());
}}
>
<RefreshIcon />
</IconButton>
</Box> </Box>
} }
additionalContent={
<TableContainer>
<Table>
<TableBody>
<ElectrumRpcUrlSetting />
<MoneroNodeUrlSetting />
</TableBody>
</Table>
</TableContainer>
}
mainContent={ mainContent={
<Typography variant="subtitle2"> <Typography variant="subtitle2">
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.
</Typography> </Typography>
} }
additionalContent={
<>
{/* Table containing the settings */}
<TableContainer>
<Table>
<TableBody>
<ElectrumRpcUrlSetting />
<MoneroNodeUrlSetting />
<FetchFiatPricesSetting />
<ThemeSetting />
</TableBody>
</Table>
</TableContainer>
{/* Reset button with a bit of spacing */}
<Box mt={theme.spacing(0.1)} />
<ResetButton />
</>
}
icon={null} icon={null}
loading={false} 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 (
<>
<Button variant="outlined" onClick={() => setModalOpen(true)}>Reset Settings</Button>
<Dialog open={modalOpen} onClose={() => setModalOpen(false)}>
<DialogTitle>Reset Settings</DialogTitle>
<DialogContent>Are you sure you want to reset the settings?</DialogContent>
<DialogActions>
<Button onClick={() => setModalOpen(false)}>Cancel</Button>
<Button color="primary" onClick={onReset}>Reset</Button>
</DialogActions>
</Dialog>
</>
)
}
/**
* 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 (
<>
<TableRow>
<TableCell>
<SettingLabel label="Query fiat prices" tooltip="Whether to fetch fiat prices via the clearnet. This is required for the price display to work. If you require total anonymity and don't use a VPN, you should disable this." />
</TableCell>
<TableCell>
<Switch
color="primary"
checked={fetchFiatPrices}
onChange={(event) => dispatch(setFetchFiatPrices(event.currentTarget.checked))}
/>
</TableCell>
</TableRow>
{fetchFiatPrices ? <FiatCurrencySetting /> : <></>}
</>
);
}
/**
* 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 (
<TableRow>
<TableCell>
<SettingLabel label="Fiat currency" tooltip="This is the currency that the price display will show prices in." />
</TableCell>
<TableCell>
<Select
value={fiatCurrency}
onChange={onChange}
variant="outlined"
fullWidth
>
{Object.values(FiatCurrency).map((currency) => (
<MenuItem key={currency} value={currency}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<Box>{currency}</Box>
<Box>{currencySymbol(currency)}</Box>
</Box>
</MenuItem>
))}
</Select>
</TableCell>
</TableRow>
);
}
/**
* URL validation function, forces the URL to be in the format of "protocol://host:port/"
*/
function isValidUrl(url: string, allowedProtocols: string[]): boolean { function isValidUrl(url: string, allowedProtocols: string[]): boolean {
const urlPattern = new RegExp(`^(${allowedProtocols.join("|")})://[^\\s]+:\\d+/?$`); const urlPattern = new RegExp(`^(${allowedProtocols.join("|")})://[^\\s]+:\\d+/?$`);
return urlPattern.test(url); return urlPattern.test(url);
} }
/**
* A setting that allows you to select the Electrum RPC URL to use.
*/
function ElectrumRpcUrlSetting() { function ElectrumRpcUrlSetting() {
const electrumRpcUrl = useSettings((s) => s.electrum_rpc_url); const [tableVisible, setTableVisible] = useState(false);
const dispatch = useAppDispatch(); const network = getNetwork();
function isValid(url: string): boolean { const isValid = (url: string) => isValidUrl(url, ["ssl", "tcp"]);
return isValidUrl(url, ["ssl", "tcp"]);
}
return ( return (
<TableRow> <TableRow>
@ -94,22 +212,27 @@ function ElectrumRpcUrlSetting() {
<SettingLabel label="Custom Electrum RPC URL" tooltip="This is the URL of the Electrum server that the GUI will connect to. It is used to sync Bitcoin transactions. If you leave this field empty, the GUI will choose from a list of known servers at random." /> <SettingLabel label="Custom Electrum RPC URL" tooltip="This is the URL of the Electrum server that the GUI will connect to. It is used to sync Bitcoin transactions. If you leave this field empty, the GUI will choose from a list of known servers at random." />
</TableCell> </TableCell>
<TableCell> <TableCell>
<ValidatedTextField <IconButton
label="Electrum RPC URL" onClick={() => setTableVisible(true)}
value={electrumRpcUrl} >
{<Edit />}
</IconButton>
{tableVisible ? <NodeTableModal
open={tableVisible}
onClose={() => setTableVisible(false)}
network={network}
blockchain={Blockchain.Bitcoin}
isValid={isValid} isValid={isValid}
onValidatedChange={(value) => {
dispatch(setElectrumRpcUrl(value));
}}
fullWidth
placeholder={PLACEHOLDER_ELECTRUM_RPC_URL} placeholder={PLACEHOLDER_ELECTRUM_RPC_URL}
allowEmpty /> : <></>}
/>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
} }
/**
* A label for a setting, with a tooltip icon.
*/
function SettingLabel({ label, tooltip }: { label: ReactNode, tooltip: string | null }) { function SettingLabel({ label, tooltip }: { label: ReactNode, tooltip: string | null }) {
return <Box style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}> return <Box style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<Box> <Box>
@ -123,32 +246,239 @@ function SettingLabel({ label, tooltip }: { label: ReactNode, tooltip: string |
</Box> </Box>
} }
/**
* A setting that allows you to select the Monero Node URL to use.
*/
function MoneroNodeUrlSetting() { function MoneroNodeUrlSetting() {
const moneroNodeUrl = useSettings((s) => s.monero_node_url); const network = getNetwork();
const dispatch = useAppDispatch(); const [tableVisible, setTableVisible] = useState(false);
function isValid(url: string): boolean { const isValid = (url: string) => isValidUrl(url, ["http"]);
return isValidUrl(url, ["http"]);
}
return ( return (
<TableRow> <TableRow>
<TableCell> <TableCell>
<SettingLabel label="Custom Monero Node URL" tooltip="This is the URL of the Monero node that the GUI will connect to. Ensure the node is listening for RPC connections over HTTP. If you leave this field empty, the GUI will choose from a list of known nodes at random." /> <SettingLabel label="Custom Monero Node URL" tooltip="This is the URL of the Monero node that the GUI will connect to. Ensure the node is listening for RPC connections over HTTP. If you leave this field empty, the GUI will choose from a list of known nodes at random." />
</TableCell> </TableCell>
<TableCell> <TableCell>
<ValidatedTextField <IconButton
label="Monero Node URL" onClick={() => setTableVisible(!tableVisible)}
value={moneroNodeUrl} >
<Edit />
</IconButton>
{tableVisible ? <NodeTableModal
open={tableVisible}
onClose={() => setTableVisible(false)}
network={network}
blockchain={Blockchain.Monero}
isValid={isValid} isValid={isValid}
onValidatedChange={(value) => {
dispatch(setMoneroNodeUrl(value));
}}
fullWidth
placeholder={PLACEHOLDER_MONERO_NODE_URL} placeholder={PLACEHOLDER_MONERO_NODE_URL}
allowEmpty /> : <></>}
/>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
} }
/**
* 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 (
<TableRow>
<TableCell>
<SettingLabel label="Theme" tooltip="This is the theme of the GUI." />
</TableCell>
<TableCell>
<Select
value={theme}
onChange={(e) => dispatch(setTheme(e.target.value as Theme))}
variant="outlined"
fullWidth
>
{/** Create an option for each theme variant */}
{Object.values(Theme).map((themeValue) => (
<MenuItem key={themeValue} value={themeValue}>
{themeValue.charAt(0).toUpperCase() + themeValue.slice(1)}
</MenuItem>
))}
</Select>
</TableCell>
</TableRow>
);
}
/**
* 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 (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Available Nodes</DialogTitle>
<DialogContent>
<Typography variant="subtitle2">
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.
</Typography>
<NodeTable network={network} blockchain={blockchain} isValid={isValid} placeholder={placeholder} />
</DialogContent>
<DialogActions>
<Button onClick={onClose} size="large">Close</Button>
</DialogActions>
</Dialog>
)
}
/**
* 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) => <svg width={radius * 2} height={radius * 2} viewBox={`0 0 ${radius * 2} ${radius * 2}`}>
<circle cx={radius} cy={radius} r={radius} fill={color} />
</svg>;
// 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 <Tooltip title={"This node is available and responding to RPC requests"}>
{circle(theme.palette.success.dark)}
</Tooltip>;
case false:
return <Tooltip title={"This node is not available or not responding to RPC requests"}>
{circle(theme.palette.error.dark)}
</Tooltip>;
default:
console.log(`Unknown status for node ${node}: ${nodeStatuses[node]}`);
return <Tooltip title={"The status of this node is currently unknown"}>
<HourglassEmpty />
</Tooltip>;
}
}
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 (
<Tooltip title={"Move this node to the top of the list"}>
<IconButton onClick={() => onMoveUpNode(node)}>
<ArrowUpward />
</IconButton>
</Tooltip>
)
}
return (
<TableContainer component={Paper} style={{ marginTop: '1rem' }} elevation={0}>
<Table size="small">
{/* Table header row */}
<TableHead>
<TableRow>
<TableCell align="center">Node URL</TableCell>
<TableCell align="center">Status</TableCell>
<TableCell align="center">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{/* Table body rows: one for each node */}
{availableNodes.map((node, index) => (
<TableRow key={index}>
{/* Node URL */}
<TableCell>
<Typography variant="overline">{node}</Typography>
</TableCell>
{/* Node status icon */}
<TableCell align="center" children={statusIcon(node)} />
{/* Remove and move buttons */}
<TableCell>
<Box style={{ display: "flex" }}>
<Tooltip
title={"Remove this node from your list"}
children={<IconButton
onClick={() => onRemoveNode(node)}
children={<Delete />}
/>}
/>
{moveUpButton(node)}
</Box>
</TableCell>
</TableRow>
))}
{/* Last row: add a new node */}
<TableRow key={-1}>
<TableCell>
<ValidatedTextField
label="Add a new node"
value={newNode}
onValidatedChange={setNewNode}
placeholder={placeholder}
fullWidth
isValid={isValid}
variant="outlined"
noErrorWhenEmpty
/>
</TableCell>
<TableCell></TableCell>
<TableCell>
<Tooltip title={"Add this node to your list"}>
<IconButton onClick={onAddNewNode} disabled={availableNodes.includes(newNode) || newNode.length === 0}>
<Add />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
)
}

View file

@ -136,7 +136,7 @@ function HasProviderSwapWidget({
<Box className={classes.inner} component={Paper} elevation={5}> <Box className={classes.inner} component={Paper} elevation={5}>
<Title /> <Title />
<TextField <TextField
label="Send" label="For this many BTC"
size="medium" size="medium"
variant="outlined" variant="outlined"
value={btcFieldValue} value={btcFieldValue}
@ -152,7 +152,7 @@ function HasProviderSwapWidget({
<ArrowDownwardIcon fontSize="small" /> <ArrowDownwardIcon fontSize="small" />
</Box> </Box>
<TextField <TextField
label="Receive" label="You'd receive that many XMR"
variant="outlined" variant="outlined"
size="medium" size="medium"
value={xmrFieldValue.toFixed(6)} value={xmrFieldValue.toFixed(6)}

View file

@ -1,6 +1,5 @@
import { Box, Button, makeStyles, Typography } from "@material-ui/core"; import { Box, Button, makeStyles, Typography } from "@material-ui/core";
import SendIcon from "@material-ui/icons/Send"; import SendIcon from "@material-ui/icons/Send";
import { RpcMethod } from "models/rpcModel";
import { useState } from "react"; import { useState } from "react";
import { SatsAmount } from "renderer/components/other/Units"; import { SatsAmount } from "renderer/components/other/Units";
import { useAppSelector } from "store/hooks"; import { useAppSelector } from "store/hooks";

View file

@ -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,
};

View file

@ -1,20 +1,6 @@
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import { PersistGate } from "redux-persist/integration/react"; 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 App from "./components/App";
import { persistor, store } from "./store/storeRenderer"; import { persistor, store } from "./store/storeRenderer";

View file

@ -22,6 +22,11 @@ import {
TauriTimelockChangeEvent, TauriTimelockChangeEvent,
GetSwapInfoArgs, GetSwapInfoArgs,
ExportBitcoinWalletResponse, ExportBitcoinWalletResponse,
CheckMoneroNodeArgs,
CheckMoneroNodeResponse,
TauriSettings,
CheckElectrumNodeArgs,
CheckElectrumNodeResponse,
GetMoneroAddressesResponse, GetMoneroAddressesResponse,
} from "models/tauriModel"; } from "models/tauriModel";
import { import {
@ -38,7 +43,9 @@ import { providerToConcatenatedMultiAddr } from "utils/multiAddrUtils";
import { MoneroRecoveryResponse } from "models/rpcModel"; import { MoneroRecoveryResponse } from "models/rpcModel";
import { ListSellersResponse } from "../models/tauriModel"; import { ListSellersResponse } from "../models/tauriModel";
import logger from "utils/logger"; 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() { export async function initEventListeners() {
// This operation is in-expensive // 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 // Warning: If we reload the page while the Context is being initialized, this function will throw an error
initializeContext().catch((e) => { 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"); 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) => { listen<TauriSwapProgressEventWrapper>("swap-progress-update", (event) => {
@ -208,11 +220,46 @@ export async function listSellersAtRendezvousPoint(
} }
export async function initializeContext() { export async function initializeContext() {
console.log("Prepare: Initializing context with settings");
const network = getNetwork();
const settings = store.getState().settings; 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(); const testnet = isTestnet();
console.log("Initializing context with settings", tauriSettings);
await invokeUnsafe<void>("initialize_context", { await invokeUnsafe<void>("initialize_context", {
settings, settings: tauriSettings,
testnet, testnet,
}); });
} }
@ -221,6 +268,54 @@ export async function getWalletDescriptor() {
return await invokeNoArgs<ExportBitcoinWalletResponse>("get_wallet_descriptor"); 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> { export async function getMoneroAddresses(): Promise<GetMoneroAddressesResponse> {
return await invokeNoArgs<GetMoneroAddressesResponse>("get_monero_addresses"); return await invokeNoArgs<GetMoneroAddressesResponse>("get_monero_addresses");
} }

View file

@ -18,7 +18,7 @@ const rootPersistConfig = {
}; };
// Use Tauri's store plugin for persistent settings // 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 // Configure how settings are stored and retrieved using Tauri's storage
const settingsPersistConfig = { const settingsPersistConfig = {

View file

@ -5,7 +5,7 @@ import rpcSlice from "./features/rpcSlice";
import swapReducer from "./features/swapSlice"; import swapReducer from "./features/swapSlice";
import torSlice from "./features/torSlice"; import torSlice from "./features/torSlice";
import settingsSlice from "./features/settingsSlice"; import settingsSlice from "./features/settingsSlice";
import nodesSlice from "./features/nodesSlice";
export const reducers = { export const reducers = {
swap: swapReducer, swap: swapReducer,
providers: providersSlice, providers: providersSlice,
@ -14,4 +14,5 @@ export const reducers = {
alerts: alertsSlice, alerts: alertsSlice,
rates: ratesSlice, rates: ratesSlice,
settings: settingsSlice, settings: settingsSlice,
nodes: nodesSlice,
}; };

View file

@ -1,9 +1,18 @@
import { ExtendedProviderStatus } from "models/apiModel"; import { ExtendedProviderStatus } from "models/apiModel";
import { splitPeerIdFromMultiAddress } from "utils/parseUtils"; import { splitPeerIdFromMultiAddress } from "utils/parseUtils";
import { getMatches } from '@tauri-apps/plugin-cli'; import { getMatches } from '@tauri-apps/plugin-cli';
import { Network } from "./features/settingsSlice";
const matches = await getMatches(); const matches = await getMatches();
export function getNetwork(): Network {
if (isTestnet()) {
return Network.Testnet;
} else {
return Network.Mainnet;
}
}
export function isTestnet() { export function isTestnet() {
return matches.args.testnet?.value === true return matches.args.testnet?.value === true
} }

View file

@ -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;

View file

@ -28,9 +28,14 @@ const ratesSlice = createSlice({
setXmrBtcRate: (state, action: PayloadAction<number>) => { setXmrBtcRate: (state, action: PayloadAction<number>) => {
state.xmrBtcRate = action.payload; 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; export default ratesSlice.reducer;

View file

@ -4,7 +4,6 @@ import {
TauriLogEvent, TauriLogEvent,
GetSwapInfoResponse, GetSwapInfoResponse,
TauriContextStatusEvent, TauriContextStatusEvent,
TauriDatabaseStateEvent,
TauriTimelockChangeEvent, TauriTimelockChangeEvent,
} from "models/tauriModel"; } from "models/tauriModel";
import { MoneroRecoveryResponse } from "../../models/rpcModel"; import { MoneroRecoveryResponse } from "../../models/rpcModel";

View file

@ -1,38 +1,151 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { TauriSettings } from "models/tauriModel"; import { Theme } from "renderer/components/theme";
const initialState: TauriSettings = { export interface SettingsState {
electrum_rpc_url: null, /// This is an ordered list of node urls for each network and blockchain
monero_node_url: null, 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({ const alertsSlice = createSlice({
name: "settings", name: "settings",
initialState, initialState,
reducers: { reducers: {
setElectrumRpcUrl(slice, action: PayloadAction<string | null>) { moveUpNode(slice, action: PayloadAction<{ network: Network, type: Blockchain, node: string }>) {
if (action.payload === null || action.payload === "") { const index = slice.nodes[action.payload.network][action.payload.type].indexOf(action.payload.node);
slice.electrum_rpc_url = null; if (index > 0) {
} else { const temp = slice.nodes[action.payload.network][action.payload.type][index];
slice.electrum_rpc_url = action.payload; 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>) { setTheme(slice, action: PayloadAction<Theme>) {
if (action.payload === null || action.payload === "") { slice.theme = action.payload;
slice.monero_node_url = null;
} else {
slice.monero_node_url = action.payload;
}
}, },
resetSettings(slice) { 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(_) {
return initialState; return initialState;
} }
}, },
}); });
export const { export const {
setElectrumRpcUrl, moveUpNode,
setMoneroNodeUrl, setTheme,
addNode,
removeNode,
resetSettings, resetSettings,
setFetchFiatPrices,
setFiatCurrency,
} = alertsSlice.actions; } = alertsSlice.actions;
export default alertsSlice.reducer; export default alertsSlice.reducer;

View file

@ -5,7 +5,9 @@ import type { AppDispatch, RootState } from "renderer/store/storeRenderer";
import { parseDateString } from "utils/parseUtils"; import { parseDateString } from "utils/parseUtils";
import { useMemo } from "react"; import { useMemo } from "react";
import { isCliLogRelatedToSwap } from "models/cliModel"; 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 useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector; export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
@ -84,6 +86,17 @@ export function useSwapInfosSortedByDate() {
); );
} }
export function useSettings<T>(selector: (settings: TauriSettings) => T): T { export function useRates<T>(selector: (rates: RatesState) => T): T {
return useAppSelector((state) => selector(state.settings)); 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);
} }

View file

@ -1,7 +1,10 @@
import { createListenerMiddleware } from "@reduxjs/toolkit"; import { createListenerMiddleware } from "@reduxjs/toolkit";
import { getAllSwapInfos, checkBitcoinBalance } from "renderer/rpc"; import { getAllSwapInfos, checkBitcoinBalance, updateAllNodeStatuses } from "renderer/rpc";
import logger from "utils/logger"; import logger from "utils/logger";
import { contextStatusEventReceived } from "store/features/rpcSlice"; 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() { export function createMainListeners() {
const listener = createListenerMiddleware(); 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; return listener;
} }

View file

@ -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;
}
}

View file

@ -902,7 +902,7 @@
dependencies: dependencies:
"@tauri-apps/api" "^2.0.0" "@tauri-apps/api" "^2.0.0"
"@tauri-apps/plugin-store@2.1.0": "@tauri-apps/plugin-store@^2.1.0":
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-store/-/plugin-store-2.1.0.tgz#02d58e068e52c314417a7df34f3c39eb2b151aa8" resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-store/-/plugin-store-2.1.0.tgz#02d58e068e52c314417a7df34f3c39eb2b151aa8"
integrity sha512-GADqrc17opUKYIAKnGHIUgEeTZ2wJGu1ZITKQ1WMuOFdv8fvXRFBAqsqPjE3opgWohbczX6e1NpwmZK1AnuWVw== integrity sha512-GADqrc17opUKYIAKnGHIUgEeTZ2wJGu1ZITKQ1WMuOFdv8fvXRFBAqsqPjE3opgWohbczX6e1NpwmZK1AnuWVw==

View file

@ -4,10 +4,11 @@ use std::sync::Arc;
use swap::cli::{ use swap::cli::{
api::{ api::{
request::{ request::{
BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, ExportBitcoinWalletArgs, GetHistoryArgs, BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, CheckElectrumNodeArgs,
GetLogsArgs, GetMoneroAddressesArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, CheckElectrumNodeResponse, CheckMoneroNodeArgs, CheckMoneroNodeResponse,
ListSellersArgs, MoneroRecoveryArgs, ResumeSwapArgs, SuspendCurrentSwapArgs, ExportBitcoinWalletArgs, GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs,
WithdrawBtcArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs,
ResumeSwapArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs,
}, },
tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings}, tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings},
Context, ContextBuilder, Context, ContextBuilder,
@ -161,6 +162,8 @@ pub fn run() {
cancel_and_refund, cancel_and_refund,
is_context_available, is_context_available,
initialize_context, initialize_context,
check_monero_node,
check_electrum_node,
get_wallet_descriptor, get_wallet_descriptor,
]) ])
.setup(setup) .setup(setup)
@ -202,7 +205,6 @@ tauri_command!(monero_recovery, MoneroRecoveryArgs);
tauri_command!(get_logs, GetLogsArgs); tauri_command!(get_logs, GetLogsArgs);
tauri_command!(list_sellers, ListSellersArgs); tauri_command!(list_sellers, ListSellersArgs);
tauri_command!(cancel_and_refund, CancelAndRefundArgs); tauri_command!(cancel_and_refund, CancelAndRefundArgs);
// These commands require no arguments // These commands require no arguments
tauri_command!(get_wallet_descriptor, ExportBitcoinWalletArgs, no_args); tauri_command!(get_wallet_descriptor, ExportBitcoinWalletArgs, no_args);
tauri_command!(suspend_current_swap, SuspendCurrentSwapArgs, 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()) 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 to initialize the Context
#[tauri::command] #[tauri::command]
async fn initialize_context( async fn initialize_context(

View file

@ -1,6 +1,6 @@
{ {
"productName": "UnstoppableSwap (new)", "productName": "UnstoppableSwap",
"version": "0.7.2", "version": "1.0.0-alpha.1",
"identifier": "net.unstoppableswap.gui", "identifier": "net.unstoppableswap.gui",
"build": { "build": {
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
@ -13,7 +13,7 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "unstoppableswap-gui-rs", "title": "UnstoppableSwap",
"width": 800, "width": 800,
"height": 600 "height": 600
} }

View file

@ -69,7 +69,7 @@ impl Wallet {
err => err?, 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(); let network = wallet.network();
@ -722,9 +722,9 @@ pub struct Client {
} }
impl 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() let config = bdk::electrum_client::ConfigBuilder::default()
.retry(5) .retry(retry_count)
.build(); .build();
let electrum = bdk::electrum_client::Client::from_config(electrum_rpc_url.as_str(), config) let electrum = bdk::electrum_client::Client::from_config(electrum_rpc_url.as_str(), config)

View file

@ -324,15 +324,6 @@ impl ContextBuilder {
) )
.await?; .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 // We initialize the Monero wallet below
// To display the progress to the user, we emit events to the Tauri frontend // To display the progress to the user, we emit events to the Tauri frontend
self.tauri_handle self.tauri_handle
@ -360,6 +351,15 @@ impl ContextBuilder {
let tor_socks5_port = self.tor.map_or(9050, |tor| tor.tor_socks5_port); 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 { let context = Context {
db, db,
bitcoin_wallet, bitcoin_wallet,
@ -473,6 +473,11 @@ async fn init_monero_wallet(
let monero_wallet_rpc = monero::WalletRpc::new(data_dir.join("monero")).await?; 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 let monero_wallet_rpc_process = monero_wallet_rpc
.run(network, Some(monero_daemon_address)) .run(network, Some(monero_daemon_address))
.await .await

View file

@ -1,19 +1,22 @@
use super::tauri_bindings::TauriHandle; 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::tauri_bindings::{TauriEmitter, TauriSwapProgressEvent};
use crate::cli::api::Context; use crate::cli::api::Context;
use crate::cli::{list_sellers as list_sellers_impl, EventLoop, Seller, SellerStatus}; use crate::cli::{list_sellers as list_sellers_impl, EventLoop, Seller, SellerStatus};
use crate::common::get_logs; use crate::common::get_logs;
use crate::libp2p_ext::MultiAddrExt; use crate::libp2p_ext::MultiAddrExt;
use crate::monero::wallet_rpc::MoneroDaemon;
use crate::network::quote::{BidQuote, ZeroQuoteReceived}; use crate::network::quote::{BidQuote, ZeroQuoteReceived};
use crate::network::swarm; use crate::network::swarm;
use crate::protocol::bob::{BobState, Swap}; use crate::protocol::bob::{BobState, Swap};
use crate::protocol::{bob, State}; use crate::protocol::{bob, State};
use crate::{bitcoin, cli, monero, rpc}; use crate::{bitcoin, cli, monero, rpc};
use ::bitcoin::Txid; use ::bitcoin::Txid;
use ::monero::Network;
use anyhow::{bail, Context as AnyContext, Result}; use anyhow::{bail, Context as AnyContext, Result};
use libp2p::core::Multiaddr; use libp2p::core::Multiaddr;
use libp2p::PeerId; use libp2p::PeerId;
use once_cell::sync::Lazy;
use qrcode::render::unicode; use qrcode::render::unicode;
use qrcode::QrCode; use qrcode::QrCode;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -25,6 +28,7 @@ use std::net::SocketAddr;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use thiserror::Error;
use tracing::debug_span; use tracing::debug_span;
use tracing::Instrument; use tracing::Instrument;
use tracing::Span; use tracing::Span;
@ -1294,3 +1298,77 @@ where
Ok((btc_swap_amount, fees)) 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(),
})
}
}

View file

@ -1,5 +1,5 @@
pub mod wallet; pub mod wallet;
mod wallet_rpc; pub mod wallet_rpc;
pub use ::monero::network::Network; pub use ::monero::network::Network;
pub use ::monero::{Address, PrivateKey, PublicKey}; pub use ::monero::{Address, PrivateKey, PublicKey};

View file

@ -4,6 +4,7 @@ use big_bytes::BigByte;
use data_encoding::HEXLOWER; use data_encoding::HEXLOWER;
use futures::{StreamExt, TryStreamExt}; use futures::{StreamExt, TryStreamExt};
use monero_rpc::wallet::{Client, MoneroWalletRpc as _}; use monero_rpc::wallet::{Client, MoneroWalletRpc as _};
use once_cell::sync::Lazy;
use reqwest::header::CONTENT_LENGTH; use reqwest::header::CONTENT_LENGTH;
use reqwest::Url; use reqwest::Url;
use serde::Deserialize; use serde::Deserialize;
@ -22,24 +23,26 @@ use tokio_util::io::StreamReader;
// See: https://www.moneroworld.com/#nodes, https://monero.fail // See: https://www.moneroworld.com/#nodes, https://monero.fail
// We don't need any testnet nodes because we don't support testnet at all // We don't need any testnet nodes because we don't support testnet at all
const MONERO_DAEMONS: [MoneroDaemon; 16] = [ 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("xmr-node.cakewallet.com", 18081, Network::Mainnet),
MoneroDaemon::new("nodes.hashvault.pro", 18081, Network::Mainnet), MoneroDaemon::new("nodex.monerujo.io", 18081, Network::Mainnet),
MoneroDaemon::new("p2pmd.xmrvsbeast.com", 18081, Network::Mainnet), MoneroDaemon::new("nodes.hashvault.pro", 18081, Network::Mainnet),
MoneroDaemon::new("node.monerodevs.org", 18089, Network::Mainnet), MoneroDaemon::new("p2pmd.xmrvsbeast.com", 18081, Network::Mainnet),
MoneroDaemon::new("xmr-node-usa-east.cakewallet.com", 18081, Network::Mainnet), MoneroDaemon::new("node.monerodevs.org", 18089, Network::Mainnet),
MoneroDaemon::new("xmr-node-uk.cakewallet.com", 18081, Network::Mainnet), MoneroDaemon::new("xmr-node-usa-east.cakewallet.com", 18081, Network::Mainnet),
MoneroDaemon::new("node.community.rino.io", 18081, Network::Mainnet), MoneroDaemon::new("xmr-node-uk.cakewallet.com", 18081, Network::Mainnet),
MoneroDaemon::new("testingjohnross.com", 20031, Network::Mainnet), MoneroDaemon::new("node.community.rino.io", 18081, Network::Mainnet),
MoneroDaemon::new("xmr.litepay.ch", 18081, Network::Mainnet), MoneroDaemon::new("testingjohnross.com", 20031, Network::Mainnet),
MoneroDaemon::new("node.trocador.app", 18089, Network::Mainnet), MoneroDaemon::new("xmr.litepay.ch", 18081, Network::Mainnet),
MoneroDaemon::new("stagenet.xmr-tw.org", 38081, Network::Stagenet), MoneroDaemon::new("node.trocador.app", 18089, Network::Mainnet),
MoneroDaemon::new("node.monerodevs.org", 38089, Network::Stagenet), MoneroDaemon::new("stagenet.xmr-tw.org", 38081, Network::Stagenet),
MoneroDaemon::new("singapore.node.xmr.pm", 38081, Network::Stagenet), MoneroDaemon::new("node.monerodevs.org", 38089, Network::Stagenet),
MoneroDaemon::new("xmr-lux.boldsuck.org", 38081, Network::Stagenet), MoneroDaemon::new("singapore.node.xmr.pm", 38081, Network::Stagenet),
MoneroDaemon::new("stagenet.community.rino.io", 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")))] #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
compile_error!("unsupported operating system"); compile_error!("unsupported operating system");
@ -87,26 +90,26 @@ pub struct WalletRpcProcess {
port: u16, port: u16,
} }
#[derive(Debug, Copy, Clone)] #[derive(Debug, Clone)]
struct MoneroDaemon { pub struct MoneroDaemon {
address: &'static str, address: String,
port: u16, port: u16,
network: Network, network: Network,
} }
impl MoneroDaemon { impl MoneroDaemon {
const fn new(address: &'static str, port: u16, network: Network) -> Self { pub fn new(address: impl Into<String>, port: u16, network: Network) -> MoneroDaemon {
Self { MoneroDaemon {
address, address: address.into(),
port, port,
network, network,
} }
} }
pub fn from_str(address: String, network: Network) -> Result<Self, Error> { pub fn from_str(address: impl Into<String>, network: Network) -> Result<MoneroDaemon, Error> {
let (address, port) = extract_host_and_port(address)?; let (address, port) = extract_host_and_port(address.into())?;
Ok(Self { Ok(MoneroDaemon {
address, address,
port, port,
network, network,
@ -114,7 +117,7 @@ impl MoneroDaemon {
} }
/// Checks if the Monero daemon is available by sending a request to its `get_info` endpoint. /// 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 url = format!("http://{}:{}/get_info", self.address, self.port);
let res = client let res = client
.get(url) .get(url)
@ -162,15 +165,14 @@ async fn choose_monero_daemon(network: Network) -> Result<MoneroDaemon, Error> {
.build()?; .build()?;
// We only want to check for daemons that match the specified network // We only want to check for daemons that match the specified network
let network_matching_daemons = MONERO_DAEMONS let daemons = &*MONERO_DAEMONS;
.iter() let network_matching_daemons = daemons.iter().filter(|daemon| daemon.network == network);
.filter(|daemon| daemon.network == network);
for daemon in network_matching_daemons { for daemon in network_matching_daemons {
match daemon.is_available(&client).await { match daemon.is_available(&client).await {
Ok(true) => { Ok(true) => {
tracing::debug!(%daemon, "Found available Monero daemon"); tracing::debug!(%daemon, "Found available Monero daemon");
return Ok(*daemon); return Ok(daemon.clone());
} }
Err(err) => { Err(err) => {
tracing::debug!(%err, %daemon, "Failed to connect to Monero daemon"); tracing::debug!(%err, %daemon, "Failed to connect to Monero daemon");
@ -402,7 +404,6 @@ impl WalletRpc {
line?; line?;
} }
// Send a json rpc request to make sure monero_wallet_rpc is ready
Client::localhost(port)?.get_version().await?; Client::localhost(port)?.get_version().await?;
Ok(WalletRpcProcess { 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 "://") // Strip the protocol (anything before "://")
let stripped_address = if let Some(pos) = address.find("://") { let stripped_address = if let Some(pos) = address.find("://") {
address[(pos + 3)..].to_string() 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 host = parts[0].to_string();
let port = parts[1].parse::<u16>()?; let port = parts[1].parse::<u16>()?;
// Leak the host string to create a 'static lifetime string return Ok((host, port));
let static_str_host: &'static str = Box::leak(host.into_boxed_str());
return Ok((static_str_host, port));
} }
bail!( bail!(