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
# Env files
.env.*
.env*

View file

@ -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",

View file

@ -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<number> {
async function fetchCurrencyPrice(currency: string, fiatCurrency: FiatCurrency): Promise<number> {
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<number> {
async function fetchXmrBtcRate(): Promise<number> {
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<number> {
}
export async function fetchBtcPrice(): Promise<number> {
return fetchCurrencyUsdPrice("bitcoin");
async function fetchBtcPrice(fiatCurrency: FiatCurrency): Promise<number> {
return fetchCurrencyPrice("bitcoin", fiatCurrency);
}
export async function fetchXmrPrice(): Promise<number> {
return fetchCurrencyUsdPrice("monero");
async function fetchXmrPrice(fiatCurrency: FiatCurrency): Promise<number> {
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 { 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,20 +29,27 @@ 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
},
},
});
export default function App() {
useEffect(() => {
fetchInitialData();
initEventListeners();
}, []);
const theme = useSettings((s) => s.theme);
return (
<ThemeProvider theme={themes[theme]}>
<GlobalSnackbarProvider>
<CssBaseline />
<Router>
<Navigation />
<InnerContent />
<UpdaterDialog />
</Router>
</GlobalSnackbarProvider>
</ThemeProvider>
);
}
function InnerContent() {
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() {
try {
const providerList = await fetchProvidersViaHttp();
@ -93,6 +81,16 @@ async function fetchInitialData() {
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 {
const alerts = await fetchAlertsViaHttp();
store.dispatch(setAlerts(alerts));
@ -102,22 +100,13 @@ async function fetchInitialData() {
}
try {
const xmrPrice = await fetchXmrPrice();
store.dispatch(setXmrPrice(xmrPrice));
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");
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);
}

View file

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

View file

@ -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 (
<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.">
<Chip label={`Spread: ${spread.toFixed(2)} %`} />
<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={`Markup ${markup.toFixed(2)}%`} />
</Tooltip>
);
@ -74,7 +76,7 @@ export default function ProviderInfo({
<Box className={classes.chipsOuter}>
<Chip label={provider.testnet ? "Testnet" : "Mainnet"} />
{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`} />
</Tooltip>
)}
@ -93,11 +95,11 @@ export default function ProviderInfo({
</Tooltip>
)}
{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" />
</Tooltip>
)}
<ProviderSpreadChip provider={provider} />
<ProviderMarkupChip provider={provider} />
</Box>
</Box>
);

View file

@ -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 (
<Paper variant="outlined" className={classes.outer}>
<Paper variant="outlined" className={classes.outer} id={id}>
<Typography variant="subtitle1">{title}</Typography>
<Box className={classes.upperContent}>
{icon}

View file

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

View file

@ -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 (
<AmountWithUnit
amount={amount}
unit="BTC"
fixedPrecision={6}
dollarRate={btcUsdRate}
exchangeRate={btcRate}
/>
);
}
export function MoneroAmount({ amount }: { amount: Amount }) {
const xmrUsdRate = useAppSelector((state) => state.rates.xmrPrice);
const xmrRate = useAppSelector((state) => state.rates.xmrPrice);
return (
<AmountWithUnit
amount={amount}
unit="XMR"
fixedPrecision={4}
dollarRate={xmrUsdRate}
exchangeRate={xmrRate}
/>
);
}

View file

@ -6,6 +6,7 @@ interface ValidatedTextFieldProps extends Omit<TextFieldProps, "onChange" | "val
isValid: (value: string) => 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 (
<TextField

View file

@ -33,6 +33,7 @@ export default function DaemonControlBox() {
return (
<InfoBox
id="daemon-control-box"
title={`Daemon Controller (${stringifiedDaemonStatus})`}
mainContent={
<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 DepositAddressInfoBox from "../../modal/swap/DepositAddressInfoBox";
@ -13,11 +13,13 @@ export default function DonateInfoBox() {
icon={<MoneroIcon />}
additionalContent={
<Typography variant="subtitle2">
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.
<p>
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 href="https://ccs.getmonero.org/proposals/mature-atomic-swaps-ecosystem.html" target="_blank">link</Link>).
</p>
<p>
If you want to support our effort event further, you can do so at this address.
</p>
</Typography>
}
/>

View file

@ -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 (
<Box className={classes.outer}>
<FeedbackInfoBox />
<DaemonControlBox />
<SettingsBox />
<ExportDataBox />
<DaemonControlBox />
<ContactInfoBox />
<DonateInfoBox />
</Box>

View file

@ -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,39 +56,43 @@ 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 (
<InfoBox
title={
<Box className={classes.title}>
Settings
<IconButton
size="small"
onClick={() => {
dispatch(resetSettings());
}}
>
<RefreshIcon />
</IconButton>
</Box>
}
mainContent={
<Typography variant="subtitle2">
Customize the settings of the GUI.
Some of these require a restart to take effect.
</Typography>
}
additionalContent={
<>
{/* Table containing the settings */}
<TableContainer>
<Table>
<TableBody>
<ElectrumRpcUrlSetting />
<MoneroNodeUrlSetting />
<FetchFiatPricesSetting />
<ThemeSetting />
</TableBody>
</Table>
</TableContainer>
}
mainContent={
<Typography variant="subtitle2">
Some of these settings require a restart to take effect.
</Typography>
{/* Reset button with a bit of spacing */}
<Box mt={theme.spacing(0.1)} />
<ResetButton />
</>
}
icon={null}
loading={false}
@ -74,19 +100,111 @@ export default function SettingsBox() {
);
}
// 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 {
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 (
<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." />
</TableCell>
<TableCell>
<ValidatedTextField
label="Electrum RPC URL"
value={electrumRpcUrl}
<IconButton
onClick={() => setTableVisible(true)}
>
{<Edit />}
</IconButton>
{tableVisible ? <NodeTableModal
open={tableVisible}
onClose={() => setTableVisible(false)}
network={network}
blockchain={Blockchain.Bitcoin}
isValid={isValid}
onValidatedChange={(value) => {
dispatch(setElectrumRpcUrl(value));
}}
fullWidth
placeholder={PLACEHOLDER_ELECTRUM_RPC_URL}
allowEmpty
/>
/> : <></>}
</TableCell>
</TableRow>
);
}
/**
* A label for a setting, with a tooltip icon.
*/
function SettingLabel({ label, tooltip }: { label: ReactNode, tooltip: string | null }) {
return <Box style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<Box>
@ -123,13 +246,14 @@ function SettingLabel({ label, tooltip }: { label: ReactNode, tooltip: string |
</Box>
}
/**
* 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 (
<TableRow>
@ -137,18 +261,224 @@ function MoneroNodeUrlSetting() {
<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>
<ValidatedTextField
label="Monero Node URL"
value={moneroNodeUrl}
<IconButton
onClick={() => setTableVisible(!tableVisible)}
>
<Edit />
</IconButton>
{tableVisible ? <NodeTableModal
open={tableVisible}
onClose={() => setTableVisible(false)}
network={network}
blockchain={Blockchain.Monero}
isValid={isValid}
onValidatedChange={(value) => {
dispatch(setMoneroNodeUrl(value));
}}
fullWidth
placeholder={PLACEHOLDER_MONERO_NODE_URL}
allowEmpty
/>
/> : <></>}
</TableCell>
</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}>
<Title />
<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)}

View file

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

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 { 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";

View file

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

View file

@ -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 = {

View file

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

View file

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

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>) => {
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;

View file

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

View file

@ -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;
},
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;
}
},
});
export const {
setElectrumRpcUrl,
setMoneroNodeUrl,
moveUpNode,
setTheme,
addNode,
removeNode,
resetSettings,
setFetchFiatPrices,
setFiatCurrency,
} = alertsSlice.actions;
export default alertsSlice.reducer;

View file

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

View file

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

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:
"@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==

View file

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

View file

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

View file

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

View file

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

View file

@ -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(),
})
}
}

View file

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

View file

@ -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,7 +23,8 @@ 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] = [
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),
@ -39,7 +41,8 @@ const MONERO_DAEMONS: [MoneroDaemon; 16] = [
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!(