feat(monero): Remote node load balancing (#420)

This commit is contained in:
Mohan 2025-06-19 01:35:34 +02:00 committed by GitHub
parent a201c13b5d
commit ff5e1c02bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 4537 additions and 154 deletions

View file

@ -4,9 +4,9 @@
"version": "0.7.0",
"type": "module",
"scripts": {
"check-bindings": "typeshare --lang=typescript --output-file __temp_bindings.ts ../swap/src && dprint fmt __temp_bindings.ts && diff -wbB __temp_bindings.ts ./src/models/tauriModel.ts && rm __temp_bindings.ts",
"gen-bindings-verbose": "RUST_LOG=debug RUST_BACKTRACE=1 typeshare --lang=typescript --output-file ./src/models/tauriModel.ts ../swap/src && dprint fmt ./src/models/tauriModel.ts",
"gen-bindings": "typeshare --lang=typescript --output-file ./src/models/tauriModel.ts ../swap/src && dprint fmt ./src/models/tauriModel.ts",
"check-bindings": "typeshare --lang=typescript --output-file __temp_bindings.ts ../swap/src ../monero-rpc-pool/src && dprint fmt __temp_bindings.ts && diff -wbB __temp_bindings.ts ./src/models/tauriModel.ts && rm __temp_bindings.ts",
"gen-bindings-verbose": "RUST_LOG=debug RUST_BACKTRACE=1 typeshare --lang=typescript --output-file ./src/models/tauriModel.ts ../swap/src ../monero-rpc-pool/src && dprint fmt ./src/models/tauriModel.ts",
"gen-bindings": "typeshare --lang=typescript --output-file ./src/models/tauriModel.ts ../swap/src ../monero-rpc-pool/src && dprint fmt ./src/models/tauriModel.ts",
"test": "vitest",
"test:ui": "vitest --ui",
"dev": "vite",

View file

@ -8,6 +8,7 @@ import {
approvalEventReceived,
backgroundProgressEventReceived,
} from "store/features/rpcSlice";
import { poolStatusReceived } from "store/features/poolSlice";
import { swapProgressEventReceived } from "store/features/swapSlice";
import logger from "utils/logger";
import {
@ -127,6 +128,10 @@ export async function setupBackgroundTasks(): Promise<void> {
store.dispatch(backgroundProgressEventReceived(eventData));
break;
case "PoolStatusUpdate":
store.dispatch(poolStatusReceived(eventData));
break;
default:
exhaustiveGuard(channelName);
}

View file

@ -24,8 +24,8 @@ export default function UnfinishedSwapsAlert() {
>
You have{" "}
{resumableSwapsCount > 1
? `${resumableSwapsCount} unfinished swaps`
: "one unfinished swap"}
? `${resumableSwapsCount} pending swaps`
: "one pending swap"}
</Alert>
);
}

View file

@ -63,7 +63,10 @@ function getActiveStep(state: SwapState | null): PathStep | null {
// Bitcoin has been locked, waiting for the counterparty to lock their XMR
case "BtcLockTxInMempool":
// We only display the first step as completed if the Bitcoin lock has been confirmed
if (latestState.content.btc_lock_confirmations > 0) {
if (
latestState.content.btc_lock_confirmations !== undefined &&
latestState.content.btc_lock_confirmations > 0
) {
return [PathType.HAPPY_PATH, 1, isReleased];
}
return [PathType.HAPPY_PATH, 0, isReleased];

View file

@ -1,4 +1,5 @@
import { TauriSwapProgressEventContent } from "models/tauriModelExt";
import { formatConfirmations } from "utils/formatUtils";
import BitcoinTransactionInfoBox from "../../BitcoinTransactionInfoBox";
import SwapStatusAlert from "renderer/components/alert/SwapStatusAlert/SwapStatusAlert";
import { useActiveSwapInfo } from "store/hooks";
@ -15,10 +16,11 @@ export default function BitcoinLockTxInMempoolPage({
return (
<Box>
{btc_lock_confirmations < BITCOIN_CONFIRMATIONS_WARNING_THRESHOLD && (
{(btc_lock_confirmations === undefined ||
btc_lock_confirmations < BITCOIN_CONFIRMATIONS_WARNING_THRESHOLD) && (
<DialogContentText>
Your Bitcoin has been locked.{" "}
{btc_lock_confirmations > 0
{btc_lock_confirmations !== undefined && btc_lock_confirmations > 0
? "We are waiting for the other party to lock their Monero."
: "We are waiting for the blockchain to confirm the transaction. Once confirmed, the other party will lock their Monero."}
</DialogContentText>
@ -30,9 +32,10 @@ export default function BitcoinLockTxInMempoolPage({
gap: "1rem",
}}
>
{btc_lock_confirmations >= BITCOIN_CONFIRMATIONS_WARNING_THRESHOLD && (
<SwapStatusAlert swap={swapInfo} isRunning={true} />
)}
{btc_lock_confirmations !== undefined &&
btc_lock_confirmations >= BITCOIN_CONFIRMATIONS_WARNING_THRESHOLD && (
<SwapStatusAlert swap={swapInfo} isRunning={true} />
)}
<BitcoinTransactionInfoBox
title="Bitcoin Lock Transaction"
txId={btc_lock_txid}
@ -43,7 +46,7 @@ export default function BitcoinLockTxInMempoolPage({
After they lock their funds and the Monero transaction receives
one confirmation, the swap will proceed to the next step.
<br />
Confirmations: {btc_lock_confirmations}
Confirmations: {formatConfirmations(btc_lock_confirmations)}
</>
}
/>

View file

@ -1,12 +1,13 @@
import { Box, DialogContentText } from "@mui/material";
import { TauriSwapProgressEventContent } from "models/tauriModelExt";
import { formatConfirmations } from "utils/formatUtils";
import MoneroTransactionInfoBox from "../../MoneroTransactionInfoBox";
export default function XmrLockTxInMempoolPage({
xmr_lock_tx_confirmations,
xmr_lock_txid,
}: TauriSwapProgressEventContent<"XmrLockTxInMempool">) {
const additionalContent = `Confirmations: ${xmr_lock_tx_confirmations}/10`;
const additionalContent = `Confirmations: ${formatConfirmations(xmr_lock_tx_confirmations, 10)}`;
return (
<Box>

View file

@ -0,0 +1,211 @@
import {
Box,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
LinearProgress,
useTheme,
} from "@mui/material";
import InfoBox from "renderer/components/modal/swap/InfoBox";
import { ReliableNodeInfo } from "models/tauriModel";
import NetworkWifiIcon from "@mui/icons-material/NetworkWifi";
import { useAppSelector } from "store/hooks";
export default function MoneroPoolHealthBox() {
const { poolStatus, isLoading } = useAppSelector((state) => ({
poolStatus: state.pool.status,
isLoading: state.pool.isLoading,
}));
const theme = useTheme();
const formatLatency = (latencyMs?: number) => {
if (latencyMs === undefined || latencyMs === null) return "N/A";
return `${Math.round(latencyMs)}ms`;
};
const formatSuccessRate = (rate: number) => {
return `${(rate * 100).toFixed(1)}%`;
};
const getHealthColor = (healthyCount: number, reliableCount: number) => {
if (reliableCount === 0) return theme.palette.error.main;
if (reliableCount < 3) return theme.palette.warning.main;
return theme.palette.success.main;
};
const renderHealthSummary = () => {
if (!poolStatus) return null;
const totalChecks =
poolStatus.successful_health_checks +
poolStatus.unsuccessful_health_checks;
const overallSuccessRate =
totalChecks > 0
? (poolStatus.successful_health_checks / totalChecks) * 100
: 0;
return (
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Chip
label={`${poolStatus.total_node_count} Total Known`}
color="info"
variant="outlined"
size="small"
/>
<Chip
label={`${poolStatus.healthy_node_count} Healthy`}
color={poolStatus.healthy_node_count > 0 ? "success" : "error"}
variant="outlined"
size="small"
/>
<Chip
label={`${(100 - overallSuccessRate).toFixed(1)}% Retry Rate`}
color={
overallSuccessRate > 80
? "success"
: overallSuccessRate > 60
? "warning"
: "error"
}
variant="outlined"
size="small"
/>
</Box>
);
};
const renderTopNodes = () => {
if (!poolStatus || poolStatus.top_reliable_nodes.length === 0) {
return (
<>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Typography variant="h6" sx={{ fontSize: "1rem" }}>
🚧
</Typography>
<Typography variant="body2" color="text.secondary">
Bootstrapping remote Monero node registry... But you can already
start swapping!
</Typography>
</Box>
</>
);
}
return (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Node URL</TableCell>
<TableCell align="right">Success Rate</TableCell>
<TableCell align="right">Avg Latency</TableCell>
</TableRow>
</TableHead>
<TableBody>
{poolStatus.top_reliable_nodes.map(
(node: ReliableNodeInfo, index: number) => (
<TableRow key={index}>
<TableCell>
<Typography
variant="caption"
sx={{ wordBreak: "break-all" }}
>
{node.url}
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="caption">
{formatSuccessRate(node.success_rate)}
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="caption">
{formatLatency(node.avg_latency_ms)}
</Typography>
</TableCell>
</TableRow>
),
)}
</TableBody>
</Table>
</TableContainer>
);
};
// Show bootstrapping message when no data is available
if (!poolStatus && !isLoading) {
return (
<InfoBox
title={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<NetworkWifiIcon />
Monero Pool Health
</Box>
}
mainContent={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Typography variant="h2" sx={{ fontSize: "1.5rem" }}>
🚧
</Typography>
<Typography variant="subtitle2">
Bootstrapping pool health monitoring. You can already start using
the app!
</Typography>
</Box>
}
additionalContent={null}
icon={null}
loading={false}
/>
);
}
return (
<InfoBox
title={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<NetworkWifiIcon />
Monero Pool Health
</Box>
}
mainContent={
<Typography variant="subtitle2">
Real-time health monitoring of the Monero node pool. Shows node
availability, success rates, and performance metrics.
</Typography>
}
additionalContent={
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{poolStatus && renderHealthSummary()}
{poolStatus && (
<Box>
<Typography variant="body2" sx={{ mb: 1, fontWeight: "medium" }}>
Health Check Statistics
</Typography>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Typography variant="caption" color="text.secondary">
Successful:{" "}
{poolStatus.successful_health_checks.toLocaleString()}
</Typography>
<Typography variant="caption" color="text.secondary">
Failed:{" "}
{poolStatus.unsuccessful_health_checks.toLocaleString()}
</Typography>
</Box>
</Box>
)}
<Box>{renderTopNodes()}</Box>
</Box>
}
icon={null}
loading={isLoading}
/>
);
}

View file

@ -20,6 +20,11 @@ import {
useTheme,
Switch,
SelectChangeEvent,
TextField,
ToggleButton,
ToggleButtonGroup,
Chip,
LinearProgress,
} from "@mui/material";
import {
addNode,
@ -35,11 +40,13 @@ import {
setFiatCurrency,
setTheme,
setTorEnabled,
setUseMoneroRpcPool,
} from "store/features/settingsSlice";
import { useAppDispatch, useNodes, useSettings } from "store/hooks";
import ValidatedTextField from "renderer/components/other/ValidatedTextField";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import HelpIcon from "@mui/icons-material/HelpOutline";
import { ReactNode, useState } from "react";
import { ReactNode, useState, useEffect } from "react";
import { Theme } from "renderer/components/theme";
import {
Add,
@ -47,12 +54,18 @@ import {
Delete,
Edit,
HourglassEmpty,
Refresh,
} from "@mui/icons-material";
import { getNetwork } from "store/config";
import { currencySymbol } from "utils/formatUtils";
import InfoBox from "renderer/components/modal/swap/InfoBox";
import { isValidMultiAddressWithPeerId } from "utils/parseUtils";
import { useAppSelector } from "store/hooks";
import { getNodeStatus } from "renderer/rpc";
import { setStatus } from "store/features/nodesSlice";
const PLACEHOLDER_ELECTRUM_RPC_URL = "ssl://blockstream.info:700";
const PLACEHOLDER_MONERO_NODE_URL = "http://xmr-node.cakewallet.com:18081";
@ -83,6 +96,7 @@ export default function SettingsBox() {
<TableBody>
<TorSettings />
<ElectrumRpcUrlSetting />
<MoneroRpcPoolSetting />
<MoneroNodeUrlSetting />
<FetchFiatPricesSetting />
<ThemeSetting />
@ -268,15 +282,21 @@ function ElectrumRpcUrlSetting() {
function SettingLabel({
label,
tooltip,
disabled = false,
}: {
label: ReactNode;
tooltip: string | null;
disabled?: boolean;
}) {
const opacity = disabled ? 0.5 : 1;
return (
<Box style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<Box
style={{ display: "flex", alignItems: "center", gap: "0.5rem", opacity }}
>
<Box>{label}</Box>
<Tooltip title={tooltip}>
<IconButton size="small">
<IconButton size="small" disabled={disabled}>
<HelpIcon />
</IconButton>
</Tooltip>
@ -285,38 +305,147 @@ function SettingLabel({
}
/**
* A setting that allows you to select the Monero Node URL to use.
* A setting that allows you to toggle between using the Monero RPC Pool and custom nodes.
*/
function MoneroRpcPoolSetting() {
const useMoneroRpcPool = useSettings((s) => s.useMoneroRpcPool);
const dispatch = useAppDispatch();
const handleChange = (
event: React.MouseEvent<HTMLElement>,
newValue: string,
) => {
if (newValue !== null) {
dispatch(setUseMoneroRpcPool(newValue === "pool"));
}
};
return (
<TableRow>
<TableCell>
<SettingLabel
label="Monero Node Selection"
tooltip="Choose between using a load-balanced pool of Monero nodes for better reliability, or configure custom Monero nodes."
/>
</TableCell>
<TableCell>
<ToggleButtonGroup
color="primary"
value={useMoneroRpcPool ? "pool" : "custom"}
exclusive
onChange={handleChange}
aria-label="Monero node selection"
size="small"
>
<ToggleButton value="pool">Pool (Recommended)</ToggleButton>
<ToggleButton value="custom">Manual</ToggleButton>
</ToggleButtonGroup>
</TableCell>
</TableRow>
);
}
/**
* A setting that allows you to configure a single Monero Node URL.
* Gets disabled when RPC pool is enabled.
*/
function MoneroNodeUrlSetting() {
const network = getNetwork();
const [tableVisible, setTableVisible] = useState(false);
const useMoneroRpcPool = useSettings((s) => s.useMoneroRpcPool);
const moneroNodeUrl = useSettings((s) => s.nodes[network][Blockchain.Monero][0] || "");
const nodeStatuses = useNodes((s) => s.nodes);
const dispatch = useAppDispatch();
const [isRefreshing, setIsRefreshing] = useState(false);
const isValid = (url: string) => isValidUrl(url, ["http"]);
const currentNodes = useSettings((s) => s.nodes[network][Blockchain.Monero]);
const handleNodeUrlChange = (newUrl: string) => {
// Remove existing nodes and add the new one
currentNodes.forEach(node => {
dispatch(removeNode({ network, type: Blockchain.Monero, node }));
});
if (newUrl.trim()) {
dispatch(addNode({ network, type: Blockchain.Monero, node: newUrl.trim() }));
}
};
const handleRefreshStatus = async () => {
// Don't refresh if pool is enabled or no node URL is configured
if (!moneroNodeUrl || useMoneroRpcPool) return;
setIsRefreshing(true);
try {
const status = await getNodeStatus(moneroNodeUrl, Blockchain.Monero, network);
// Update the status in the store
dispatch(setStatus({ node: moneroNodeUrl, status, blockchain: Blockchain.Monero }));
} catch (error) {
console.error("Failed to refresh node status:", error);
} finally {
setIsRefreshing(false);
}
};
const isValid = (url: string) => url === "" || isValidUrl(url, ["http"]);
const nodeStatus = moneroNodeUrl ? nodeStatuses[Blockchain.Monero][moneroNodeUrl] : null;
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."
tooltip={
useMoneroRpcPool
? "This setting is disabled because Monero RPC pool is enabled. Disable the RPC pool to configure a custom node."
: "This is the URL of the Monero node that the GUI will connect to. It is used to sync Monero transactions. If you leave this field empty, the GUI will choose from a list of known servers at random."
}
disabled={useMoneroRpcPool}
/>
</TableCell>
<TableCell>
<IconButton onClick={() => setTableVisible(!tableVisible)} size="large">
<Edit />
</IconButton>
{tableVisible ? (
<NodeTableModal
open={tableVisible}
onClose={() => setTableVisible(false)}
network={network}
blockchain={Blockchain.Monero}
isValid={isValid}
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<ValidatedTextField
value={moneroNodeUrl}
onValidatedChange={handleNodeUrlChange}
placeholder={PLACEHOLDER_MONERO_NODE_URL}
disabled={useMoneroRpcPool}
fullWidth
isValid={isValid}
variant="outlined"
noErrorWhenEmpty
/>
) : (
<></>
)}
<>
<Tooltip title={
useMoneroRpcPool
? "Node status checking is disabled when using the pool"
: !moneroNodeUrl
? "Enter a node URL to check status"
: "Node status"
}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Circle
color={useMoneroRpcPool || !moneroNodeUrl ? "gray" : (nodeStatus ? "green" : "red")}
/>
</Box>
</Tooltip>
<Tooltip title={
useMoneroRpcPool
? "Node status refresh is disabled when using the pool"
: !moneroNodeUrl
? "Enter a node URL to refresh status"
: "Refresh node status"
}>
<IconButton
onClick={handleRefreshStatus}
disabled={isRefreshing || useMoneroRpcPool || !moneroNodeUrl}
size="small"
>
{isRefreshing ? <HourglassEmpty /> : <Refresh />}
</IconButton>
</Tooltip>
</>
</Box>
</TableCell>
</TableRow>
);
@ -380,7 +509,7 @@ function NodeTableModal({
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.
known nodes at random.
</Typography>
<NodeTable
network={network}
@ -413,38 +542,6 @@ function Circle({ color, radius = 6 }: { color: string; radius?: number }) {
);
}
/**
* Displays a status indicator for a node
*/
function NodeStatus({ status }: { status: boolean | undefined }) {
const theme = useTheme();
switch (status) {
case true:
return (
<Tooltip
title={"This node is available and responding to RPC requests"}
>
<Circle color={theme.palette.success.dark} />
</Tooltip>
);
case false:
return (
<Tooltip
title={"This node is not available or not responding to RPC requests"}
>
<Circle color={theme.palette.error.dark} />
</Tooltip>
);
default:
return (
<Tooltip title={"The status of this node is currently unknown"}>
<HourglassEmpty />
</Tooltip>
);
}
}
/**
* 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.
@ -515,7 +612,9 @@ function NodeTable({
</TableCell>
{/* Node status icon */}
<TableCell align="center">
<NodeStatus status={nodeStatuses[blockchain][node]} />
<Circle
color={nodeStatuses[blockchain][node] ? "green" : "red"}
/>
</TableCell>
{/* Remove and move buttons */}
<TableCell>
@ -582,7 +681,7 @@ export function TorSettings() {
<TableCell>
<SettingLabel
label="Use Tor"
tooltip="Tor (The Onion Router) is a decentralized network allowing for anonymous browsing. If enabled, the app will use its internal Tor client to hide your IP address from the maker. Requires a restart to take effect."
tooltip="Route network traffic through Tor to hide your IP address from the maker."
/>
</TableCell>

View file

@ -5,6 +5,7 @@ import DaemonControlBox from "./DaemonControlBox";
import SettingsBox from "./SettingsBox";
import ExportDataBox from "./ExportDataBox";
import DiscoveryBox from "./DiscoveryBox";
import MoneroPoolHealthBox from "./MoneroPoolHealthBox";
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
@ -29,6 +30,7 @@ export default function SettingsPage() {
>
<SettingsBox />
<DiscoveryBox />
<MoneroPoolHealthBox />
<ExportDataBox />
<DaemonControlBox />
<DonateInfoBox />

View file

@ -223,37 +223,20 @@ export async function initializeContext() {
const bitcoinNodes =
store.getState().settings.nodes[network][Blockchain.Bitcoin];
// For Monero nodes, check availability and use the first working one
const moneroNodes =
store.getState().settings.nodes[network][Blockchain.Monero];
let moneroNode = null;
if (moneroNodes.length > 0) {
try {
moneroNode = await Promise.any(
moneroNodes.map(async (node) => {
const isAvailable = await getNodeStatus(
node,
Blockchain.Monero,
network,
);
if (isAvailable) {
return node;
}
throw new Error(`Monero node ${node} is not available`);
}),
);
} catch {
// If no Monero node is available, use null
moneroNode = null;
}
}
// For Monero nodes, get the configured node URL and pool setting
const useMoneroRpcPool = store.getState().settings.useMoneroRpcPool;
const moneroNodes = store.getState().settings.nodes[network][Blockchain.Monero];
// Always pass the first configured monero node URL directly without checking availability
// The backend will handle whether to use the pool or the custom node
const moneroNode = moneroNodes.length > 0 ? moneroNodes[0] : null;
// Initialize Tauri settings
const tauriSettings: TauriSettings = {
electrum_rpc_urls: bitcoinNodes,
monero_node_url: moneroNode,
use_tor: useTor,
use_monero_rpc_pool: useMoneroRpcPool,
};
logger.info("Initializing context with settings", tauriSettings);
@ -325,13 +308,15 @@ export async function updateAllNodeStatuses() {
const network = getNetwork();
const settings = store.getState().settings;
// Only check Monero nodes, skip Bitcoin nodes since we pass all electrum servers
// to the backend without checking them (ElectrumBalancer handles failover)
await Promise.all(
settings.nodes[network][Blockchain.Monero].map((node) =>
updateNodeStatus(node, Blockchain.Monero, network),
),
);
// Only check Monero nodes if we're using custom nodes (not RPC pool)
// Skip Bitcoin nodes since we pass all electrum servers to the backend without checking them (ElectrumBalancer handles failover)
if (!settings.useMoneroRpcPool) {
await Promise.all(
settings.nodes[network][Blockchain.Monero].map((node) =>
updateNodeStatus(node, Blockchain.Monero, network),
),
);
}
}
export async function getMoneroAddresses(): Promise<GetMoneroAddressesResponse> {
@ -361,3 +346,9 @@ export async function saveLogFiles(
): Promise<void> {
await invokeUnsafe<void>("save_txt_files", { zipFileName, content });
}
export async function saveFilesInDialog(files: Record<string, string>) {
await invokeUnsafe<void>("save_txt_files", {
files,
});
}

View file

@ -7,6 +7,7 @@ import torSlice from "./features/torSlice";
import settingsSlice from "./features/settingsSlice";
import nodesSlice from "./features/nodesSlice";
import conversationsSlice from "./features/conversationsSlice";
import poolSlice from "./features/poolSlice";
export const reducers = {
swap: swapReducer,
@ -18,4 +19,5 @@ export const reducers = {
settings: settingsSlice,
nodes: nodesSlice,
conversations: conversationsSlice,
pool: poolSlice,
};

View file

@ -0,0 +1,31 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { PoolStatus } from "models/tauriModel";
interface PoolSlice {
status: PoolStatus | null;
isLoading: boolean;
}
const initialState: PoolSlice = {
status: null,
isLoading: true,
};
export const poolSlice = createSlice({
name: "pool",
initialState,
reducers: {
poolStatusReceived(slice, action: PayloadAction<PoolStatus>) {
slice.status = action.payload;
slice.isLoading = false;
},
poolStatusReset(slice) {
slice.status = null;
slice.isLoading = true;
},
},
});
export const { poolStatusReceived, poolStatusReset } = poolSlice.actions;
export default poolSlice.reducer;

View file

@ -17,6 +17,8 @@ export interface SettingsState {
fiatCurrency: FiatCurrency;
/// Whether to enable Tor for p2p connections
enableTor: boolean;
/// Whether to use the Monero RPC pool for load balancing (true) or custom nodes (false)
useMoneroRpcPool: boolean;
userHasSeenIntroduction: boolean;
/// List of rendezvous points
rendezvousPoints: string[];
@ -119,6 +121,7 @@ const initialState: SettingsState = {
fetchFiatPrices: false,
fiatCurrency: FiatCurrency.Usd,
enableTor: true,
useMoneroRpcPool: true, // Default to using RPC pool
userHasSeenIntroduction: false,
rendezvousPoints: DEFAULT_RENDEZVOUS_POINTS,
};
@ -206,6 +209,9 @@ const alertsSlice = createSlice({
setTorEnabled(slice, action: PayloadAction<boolean>) {
slice.enableTor = action.payload;
},
setUseMoneroRpcPool(slice, action: PayloadAction<boolean>) {
slice.useMoneroRpcPool = action.payload;
},
},
});
@ -218,6 +224,7 @@ export const {
setFetchFiatPrices,
setFiatCurrency,
setTorEnabled,
setUseMoneroRpcPool,
setUserHasSeenIntroduction,
addRendezvousPoint,
removeRendezvousPoint,

View file

@ -83,3 +83,24 @@ export function currencySymbol(currency: FiatCurrency): string | null {
return null;
}
}
/**
* Formats confirmation count, displaying "?" when the transaction state is unknown
* @param confirmations - The number of confirmations, or undefined if unknown
* @param maxConfirmations - Optional maximum confirmations to show as "X/Y" format
* @returns Formatted string showing confirmations or "?" if unknown
*/
export function formatConfirmations(
confirmations: number | undefined | null,
maxConfirmations?: number,
): string {
if (confirmations === undefined || confirmations === null) {
return maxConfirmations !== undefined ? `?/${maxConfirmations}` : "?";
}
if (maxConfirmations !== undefined) {
return `${confirmations}/${maxConfirmations}`;
}
return confirmations.toString();
}