mirror of
https://github.com/comit-network/xmr-btc-swap.git
synced 2025-05-03 15:24:53 -04:00
feat(GUI): Add settings for theme, fiat currency and remote nodes (#128)
This commit is contained in:
parent
27d6e23b93
commit
3e79bb3712
37 changed files with 1133 additions and 267 deletions
2
src-gui/.gitignore
vendored
2
src-gui/.gitignore
vendored
|
@ -28,4 +28,4 @@ dist-ssr
|
|||
src/models/tauriModel.ts
|
||||
|
||||
# Env files
|
||||
.env.*
|
||||
.env*
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,8 +76,8 @@ 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.">
|
||||
<Chip label={`${Math.round(provider.uptime * 100)} % uptime`} />
|
||||
<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>
|
||||
)}
|
||||
{provider.age ? (
|
||||
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -33,6 +33,7 @@ export default function DaemonControlBox() {
|
|||
|
||||
return (
|
||||
<InfoBox
|
||||
id="daemon-control-box"
|
||||
title={`Daemon Controller (${stringifiedDaemonStatus})`}
|
||||
mainContent={
|
||||
<CliLogsBox
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -9,19 +9,41 @@ import {
|
|||
Box,
|
||||
makeStyles,
|
||||
Tooltip,
|
||||
Select,
|
||||
MenuItem,
|
||||
TableHead,
|
||||
Paper,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
DialogTitle,
|
||||
useTheme,
|
||||
Switch,
|
||||
} from "@material-ui/core";
|
||||
import InfoBox from "renderer/components/modal/swap/InfoBox";
|
||||
import {
|
||||
removeNode,
|
||||
resetSettings,
|
||||
setElectrumRpcUrl,
|
||||
setMoneroNodeUrl,
|
||||
setFetchFiatPrices,
|
||||
setFiatCurrency,
|
||||
} from "store/features/settingsSlice";
|
||||
import { useAppDispatch, useSettings } from "store/hooks";
|
||||
import {
|
||||
addNode,
|
||||
Blockchain,
|
||||
FiatCurrency,
|
||||
moveUpNode,
|
||||
Network,
|
||||
setTheme,
|
||||
} from "store/features/settingsSlice";
|
||||
import { useAppDispatch, useAppSelector, useNodes, useSettings } from "store/hooks";
|
||||
import ValidatedTextField from "renderer/components/other/ValidatedTextField";
|
||||
import RefreshIcon from "@material-ui/icons/Refresh";
|
||||
import HelpIcon from '@material-ui/icons/HelpOutline';
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { Theme } from "renderer/components/theme";
|
||||
import { Add, ArrowUpward, Delete, Edit, HourglassEmpty } from "@material-ui/icons";
|
||||
import { getNetwork } from "store/config";
|
||||
import { currencySymbol } from "utils/formatUtils";
|
||||
|
||||
const PLACEHOLDER_ELECTRUM_RPC_URL = "ssl://blockstream.info:700";
|
||||
const PLACEHOLDER_MONERO_NODE_URL = "http://xmr-node.cakewallet.com:18081";
|
||||
|
@ -34,59 +56,155 @@ const useStyles = makeStyles((theme) => ({
|
|||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* The settings box, containing the settings for the GUI.
|
||||
*/
|
||||
export default function SettingsBox() {
|
||||
const dispatch = useAppDispatch();
|
||||
const classes = useStyles();
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<InfoBox
|
||||
title={
|
||||
<Box className={classes.title}>
|
||||
Settings
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
dispatch(resetSettings());
|
||||
}}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
additionalContent={
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<ElectrumRpcUrlSetting />
|
||||
<MoneroNodeUrlSetting />
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
}
|
||||
mainContent={
|
||||
<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>
|
||||
}
|
||||
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}
|
||||
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 {
|
||||
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,32 +246,239 @@ 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>
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
|
@ -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)}
|
||||
|
|
|
@ -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";
|
||||
|
|
56
src-gui/src/renderer/components/theme.tsx
Normal file
56
src-gui/src/renderer/components/theme.tsx
Normal 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,
|
||||
};
|
|
@ -1,20 +1,6 @@
|
|||
import { createRoot } from "react-dom/client";
|
||||
import { Provider } from "react-redux";
|
||||
import { PersistGate } from "redux-persist/integration/react";
|
||||
import { setAlerts } from "store/features/alertsSlice";
|
||||
import {
|
||||
registryConnectionFailed,
|
||||
setRegistryProviders,
|
||||
} from "store/features/providersSlice";
|
||||
import { setBtcPrice, setXmrBtcRate, setXmrPrice } from "store/features/ratesSlice";
|
||||
import logger from "../utils/logger";
|
||||
import {
|
||||
fetchAlertsViaHttp,
|
||||
fetchBtcPrice,
|
||||
fetchProvidersViaHttp,
|
||||
fetchXmrBtcRate,
|
||||
fetchXmrPrice,
|
||||
} from "./api";
|
||||
import App from "./components/App";
|
||||
import { persistor, store } from "./store/storeRenderer";
|
||||
|
||||
|
@ -27,4 +13,4 @@ root.render(
|
|||
<App />
|
||||
</PersistGate>
|
||||
</Provider>,
|
||||
);
|
||||
);
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
41
src-gui/src/store/features/nodesSlice.ts
Normal file
41
src-gui/src/store/features/nodesSlice.ts
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -4,7 +4,6 @@ import {
|
|||
TauriLogEvent,
|
||||
GetSwapInfoResponse,
|
||||
TauriContextStatusEvent,
|
||||
TauriDatabaseStateEvent,
|
||||
TauriTimelockChangeEvent,
|
||||
} from "models/tauriModel";
|
||||
import { MoneroRecoveryResponse } from "../../models/rpcModel";
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
48
src-gui/src/utils/formatUtils.ts
Normal file
48
src-gui/src/utils/formatUtils.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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==
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -4,6 +4,7 @@ use big_bytes::BigByte;
|
|||
use data_encoding::HEXLOWER;
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use monero_rpc::wallet::{Client, MoneroWalletRpc as _};
|
||||
use once_cell::sync::Lazy;
|
||||
use reqwest::header::CONTENT_LENGTH;
|
||||
use reqwest::Url;
|
||||
use serde::Deserialize;
|
||||
|
@ -22,24 +23,26 @@ use tokio_util::io::StreamReader;
|
|||
|
||||
// See: https://www.moneroworld.com/#nodes, https://monero.fail
|
||||
// We don't need any testnet nodes because we don't support testnet at all
|
||||
const MONERO_DAEMONS: [MoneroDaemon; 16] = [
|
||||
MoneroDaemon::new("xmr-node.cakewallet.com", 18081, Network::Mainnet),
|
||||
MoneroDaemon::new("nodex.monerujo.io", 18081, Network::Mainnet),
|
||||
MoneroDaemon::new("nodes.hashvault.pro", 18081, Network::Mainnet),
|
||||
MoneroDaemon::new("p2pmd.xmrvsbeast.com", 18081, Network::Mainnet),
|
||||
MoneroDaemon::new("node.monerodevs.org", 18089, Network::Mainnet),
|
||||
MoneroDaemon::new("xmr-node-usa-east.cakewallet.com", 18081, Network::Mainnet),
|
||||
MoneroDaemon::new("xmr-node-uk.cakewallet.com", 18081, Network::Mainnet),
|
||||
MoneroDaemon::new("node.community.rino.io", 18081, Network::Mainnet),
|
||||
MoneroDaemon::new("testingjohnross.com", 20031, Network::Mainnet),
|
||||
MoneroDaemon::new("xmr.litepay.ch", 18081, Network::Mainnet),
|
||||
MoneroDaemon::new("node.trocador.app", 18089, Network::Mainnet),
|
||||
MoneroDaemon::new("stagenet.xmr-tw.org", 38081, Network::Stagenet),
|
||||
MoneroDaemon::new("node.monerodevs.org", 38089, Network::Stagenet),
|
||||
MoneroDaemon::new("singapore.node.xmr.pm", 38081, Network::Stagenet),
|
||||
MoneroDaemon::new("xmr-lux.boldsuck.org", 38081, Network::Stagenet),
|
||||
MoneroDaemon::new("stagenet.community.rino.io", 38081, Network::Stagenet),
|
||||
];
|
||||
const MONERO_DAEMONS: Lazy<[MoneroDaemon; 16]> = Lazy::new(|| {
|
||||
[
|
||||
MoneroDaemon::new("xmr-node.cakewallet.com", 18081, Network::Mainnet),
|
||||
MoneroDaemon::new("nodex.monerujo.io", 18081, Network::Mainnet),
|
||||
MoneroDaemon::new("nodes.hashvault.pro", 18081, Network::Mainnet),
|
||||
MoneroDaemon::new("p2pmd.xmrvsbeast.com", 18081, Network::Mainnet),
|
||||
MoneroDaemon::new("node.monerodevs.org", 18089, Network::Mainnet),
|
||||
MoneroDaemon::new("xmr-node-usa-east.cakewallet.com", 18081, Network::Mainnet),
|
||||
MoneroDaemon::new("xmr-node-uk.cakewallet.com", 18081, Network::Mainnet),
|
||||
MoneroDaemon::new("node.community.rino.io", 18081, Network::Mainnet),
|
||||
MoneroDaemon::new("testingjohnross.com", 20031, Network::Mainnet),
|
||||
MoneroDaemon::new("xmr.litepay.ch", 18081, Network::Mainnet),
|
||||
MoneroDaemon::new("node.trocador.app", 18089, Network::Mainnet),
|
||||
MoneroDaemon::new("stagenet.xmr-tw.org", 38081, Network::Stagenet),
|
||||
MoneroDaemon::new("node.monerodevs.org", 38089, Network::Stagenet),
|
||||
MoneroDaemon::new("singapore.node.xmr.pm", 38081, Network::Stagenet),
|
||||
MoneroDaemon::new("xmr-lux.boldsuck.org", 38081, Network::Stagenet),
|
||||
MoneroDaemon::new("stagenet.community.rino.io", 38081, Network::Stagenet),
|
||||
]
|
||||
});
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
compile_error!("unsupported operating system");
|
||||
|
@ -87,26 +90,26 @@ pub struct WalletRpcProcess {
|
|||
port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
struct MoneroDaemon {
|
||||
address: &'static str,
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MoneroDaemon {
|
||||
address: String,
|
||||
port: u16,
|
||||
network: Network,
|
||||
}
|
||||
|
||||
impl MoneroDaemon {
|
||||
const fn new(address: &'static str, port: u16, network: Network) -> Self {
|
||||
Self {
|
||||
address,
|
||||
pub fn new(address: impl Into<String>, port: u16, network: Network) -> MoneroDaemon {
|
||||
MoneroDaemon {
|
||||
address: address.into(),
|
||||
port,
|
||||
network,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(address: String, network: Network) -> Result<Self, Error> {
|
||||
let (address, port) = extract_host_and_port(address)?;
|
||||
pub fn from_str(address: impl Into<String>, network: Network) -> Result<MoneroDaemon, Error> {
|
||||
let (address, port) = extract_host_and_port(address.into())?;
|
||||
|
||||
Ok(Self {
|
||||
Ok(MoneroDaemon {
|
||||
address,
|
||||
port,
|
||||
network,
|
||||
|
@ -114,7 +117,7 @@ impl MoneroDaemon {
|
|||
}
|
||||
|
||||
/// Checks if the Monero daemon is available by sending a request to its `get_info` endpoint.
|
||||
async fn is_available(&self, client: &reqwest::Client) -> Result<bool, Error> {
|
||||
pub async fn is_available(&self, client: &reqwest::Client) -> Result<bool, Error> {
|
||||
let url = format!("http://{}:{}/get_info", self.address, self.port);
|
||||
let res = client
|
||||
.get(url)
|
||||
|
@ -162,15 +165,14 @@ async fn choose_monero_daemon(network: Network) -> Result<MoneroDaemon, Error> {
|
|||
.build()?;
|
||||
|
||||
// We only want to check for daemons that match the specified network
|
||||
let network_matching_daemons = MONERO_DAEMONS
|
||||
.iter()
|
||||
.filter(|daemon| daemon.network == network);
|
||||
let daemons = &*MONERO_DAEMONS;
|
||||
let network_matching_daemons = daemons.iter().filter(|daemon| daemon.network == network);
|
||||
|
||||
for daemon in network_matching_daemons {
|
||||
match daemon.is_available(&client).await {
|
||||
Ok(true) => {
|
||||
tracing::debug!(%daemon, "Found available Monero daemon");
|
||||
return Ok(*daemon);
|
||||
return Ok(daemon.clone());
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, %daemon, "Failed to connect to Monero daemon");
|
||||
|
@ -402,7 +404,6 @@ impl WalletRpc {
|
|||
line?;
|
||||
}
|
||||
|
||||
// Send a json rpc request to make sure monero_wallet_rpc is ready
|
||||
Client::localhost(port)?.get_version().await?;
|
||||
|
||||
Ok(WalletRpcProcess {
|
||||
|
@ -486,7 +487,7 @@ impl WalletRpc {
|
|||
}
|
||||
}
|
||||
|
||||
fn extract_host_and_port(address: String) -> Result<(&'static str, u16), Error> {
|
||||
fn extract_host_and_port(address: String) -> Result<(String, u16), Error> {
|
||||
// Strip the protocol (anything before "://")
|
||||
let stripped_address = if let Some(pos) = address.find("://") {
|
||||
address[(pos + 3)..].to_string()
|
||||
|
@ -501,9 +502,7 @@ fn extract_host_and_port(address: String) -> Result<(&'static str, u16), Error>
|
|||
let host = parts[0].to_string();
|
||||
let port = parts[1].parse::<u16>()?;
|
||||
|
||||
// Leak the host string to create a 'static lifetime string
|
||||
let static_str_host: &'static str = Box::leak(host.into_boxed_str());
|
||||
return Ok((static_str_host, port));
|
||||
return Ok((host, port));
|
||||
}
|
||||
|
||||
bail!(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue