refactor(monero-rpc-pool): ureq -> raw hyper (#487)

* refactor(monero-rpc-pool): ureq -> raw hyper

* whitelist "getblocks.bin", refactor config constructors, use arti-client, record lowerst seen block height, small style changes

* display effective bandwidth

* compact wallet overview page a bit

* record latencies correctly

* add setting for monero tor routing, add ssl support for hyper, lengthen window duration for bandwidth tracker

* remove unwrap

* refactor ui

* dont fail silently tor bootstrap

* some workarounds for buggy wallet2 stuff
This commit is contained in:
Mohan 2025-08-01 12:02:07 +02:00 committed by GitHub
parent cd12d17580
commit d21baa8350
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 957 additions and 741 deletions

36
Cargo.lock generated
View file

@ -4559,9 +4559,11 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2 0.6.0", "socket2 0.6.0",
"system-configuration 0.6.1",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@ -6219,25 +6221,31 @@ name = "monero-rpc-pool"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arti-client",
"axum", "axum",
"chrono", "chrono",
"clap 4.5.41", "clap 4.5.41",
"futures", "futures",
"http-body-util",
"hyper 1.6.0",
"hyper-util",
"monero", "monero",
"monero-rpc", "monero-rpc",
"native-tls",
"rand 0.8.5", "rand 0.8.5",
"regex", "regex",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"tokio", "tokio",
"tokio-native-tls",
"tokio-test", "tokio-test",
"tor-rtcompat",
"tower 0.4.13", "tower 0.4.13",
"tower-http 0.5.2", "tower-http 0.5.2",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"typeshare", "typeshare",
"ureq",
"url", "url",
"uuid", "uuid",
] ]
@ -12872,21 +12880,6 @@ version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64 0.22.1",
"log",
"once_cell",
"rustls 0.23.29",
"rustls-pki-types",
"url",
"webpki-roots 0.26.11",
]
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.4" version = "2.5.4"
@ -13594,6 +13587,17 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
"windows-link",
"windows-result 0.3.4",
"windows-strings",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.1.2" version = "0.1.2"

View file

@ -27,9 +27,17 @@ tower-http = { version = "0.5", features = ["cors"] }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
typeshare = { workspace = true } typeshare = { workspace = true }
ureq = { version = "2.10", default-features = false, features = ["tls"] }
url = "2.0" url = "2.0"
uuid = { workspace = true } uuid = { workspace = true }
arti-client = { workspace = true, features = ["tokio"] }
tor-rtcompat = { workspace = true, features = ["tokio", "rustls"] }
http-body-util = "0.1"
hyper = { version = "1", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
native-tls = "0.2"
tokio-native-tls = "0.3"
[dev-dependencies] [dev-dependencies]
tokio-test = "0.4" tokio-test = "0.4"

View file

@ -1,27 +1,53 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)] use crate::TorClientArc;
#[derive(Clone)]
pub struct Config { pub struct Config {
pub host: String, pub host: String,
pub port: u16, pub port: u16,
pub data_dir: PathBuf, pub data_dir: PathBuf,
pub tor_client: Option<TorClientArc>,
}
impl std::fmt::Debug for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Config")
.field("host", &self.host)
.field("port", &self.port)
.field("data_dir", &self.data_dir)
.field("tor_client", &self.tor_client.is_some())
.finish()
}
} }
impl Config { impl Config {
pub fn new_with_port(host: String, port: u16, data_dir: PathBuf) -> Self { pub fn new_with_port(host: String, port: u16, data_dir: PathBuf) -> Self {
Self::new_with_port_and_tor_client(host, port, data_dir, None)
}
pub fn new_with_port_and_tor_client(
host: String,
port: u16,
data_dir: PathBuf,
tor_client: impl Into<Option<TorClientArc>>,
) -> Self {
Self { Self {
host, host,
port, port,
data_dir, data_dir,
tor_client: tor_client.into(),
} }
} }
pub fn new_random_port(host: String, data_dir: PathBuf) -> Self { pub fn new_random_port(data_dir: PathBuf) -> Self {
Self { Self::new_random_port_with_tor_client(data_dir, None)
host, }
port: 0,
data_dir, pub fn new_random_port_with_tor_client(
} data_dir: PathBuf,
tor_client: impl Into<Option<TorClientArc>>,
) -> Self {
Self::new_with_port_and_tor_client("127.0.0.1".to_string(), 0, data_dir, tor_client)
} }
} }

View file

@ -1,6 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use arti_client::TorClient;
use axum::{ use axum::{
routing::{any, get}, routing::{any, get},
Router, Router,
@ -8,9 +9,13 @@ use axum::{
use monero::Network; use monero::Network;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tor_rtcompat::tokio::TokioRustlsRuntime;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use tracing::{error, info}; use tracing::{error, info};
/// Type alias for the Tor client used throughout the crate
pub type TorClientArc = Arc<TorClient<TokioRustlsRuntime>>;
pub trait ToNetworkString { pub trait ToNetworkString {
fn to_network_string(&self) -> String; fn to_network_string(&self) -> String;
} }
@ -39,7 +44,7 @@ use proxy::{proxy_handler, stats_handler};
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub node_pool: Arc<NodePool>, pub node_pool: Arc<NodePool>,
pub http_client: ureq::Agent, pub tor_client: Option<TorClientArc>,
} }
/// Manages background tasks for the RPC pool /// Manages background tasks for the RPC pool
@ -104,17 +109,9 @@ async fn create_app_with_receiver(
status_update_handle, status_update_handle,
}; };
// Create shared HTTP client with connection pooling and keep-alive
// TODO: Add dangerous certificate acceptance equivalent to reqwest's danger_accept_invalid_certs(true)
let http_client = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(30))
.max_idle_connections(100)
.max_idle_connections_per_host(10)
.build();
let app_state = AppState { let app_state = AppState {
node_pool, node_pool,
http_client, tor_client: config.tor_client,
}; };
// Build the app // Build the app
@ -177,14 +174,8 @@ pub async fn start_server_with_random_port(
tokio::sync::broadcast::Receiver<PoolStatus>, tokio::sync::broadcast::Receiver<PoolStatus>,
PoolHandle, PoolHandle,
)> { )> {
// Clone the host before moving config
let host = config.host.clone(); let host = config.host.clone();
let (app, status_receiver, pool_handle) = create_app_with_receiver(config, network).await?;
// If port is 0, the system will assign a random available port
let config_with_random_port = Config::new_random_port(config.host, config.data_dir);
let (app, status_receiver, pool_handle) =
create_app_with_receiver(config_with_random_port, network).await?;
// Bind to port 0 to get a random available port // Bind to port 0 to get a random available port
let listener = tokio::net::TcpListener::bind(format!("{}:0", host)).await?; let listener = tokio::net::TcpListener::bind(format!("{}:0", host)).await?;
@ -209,18 +200,3 @@ pub async fn start_server_with_random_port(
Ok((server_info, status_receiver, pool_handle)) Ok((server_info, status_receiver, pool_handle))
} }
/// Start a server with a random port and custom data directory for library usage
/// Returns the server info with the actual port used, a receiver for pool status updates, and pool handle
pub async fn start_server_with_random_port_and_data_dir(
config: Config,
network: Network,
data_dir: std::path::PathBuf,
) -> Result<(
ServerInfo,
tokio::sync::broadcast::Receiver<PoolStatus>,
PoolHandle,
)> {
let config_with_data_dir = Config::new_random_port(config.host, data_dir);
start_server_with_random_port(config_with_data_dir, network).await
}

View file

@ -1,3 +1,4 @@
use arti_client::{TorClient, TorClientConfig};
use clap::Parser; use clap::Parser;
use monero_rpc_pool::{config::Config, run_server}; use monero_rpc_pool::{config::Config, run_server};
use tracing::info; use tracing::info;
@ -47,6 +48,11 @@ struct Args {
#[arg(short, long)] #[arg(short, long)]
#[arg(help = "Enable verbose logging")] #[arg(help = "Enable verbose logging")]
verbose: bool, verbose: bool,
#[arg(short, long)]
#[arg(help = "Enable Tor routing")]
#[arg(default_value = "true")]
tor: bool,
} }
#[tokio::main] #[tokio::main]
@ -54,16 +60,46 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse(); let args = Args::parse();
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(EnvFilter::new("trace")) .with_env_filter(EnvFilter::new("info"))
.with_target(false) .with_target(false)
.with_file(true) .with_file(true)
.with_line_number(true) .with_line_number(true)
.init(); .init();
let config = Config::new_with_port( let tor_client = if args.tor {
let config = TorClientConfig::default();
let runtime = tor_rtcompat::tokio::TokioRustlsRuntime::current()
.expect("We are always running with tokio");
let client = TorClient::with_runtime(runtime)
.config(config)
.create_unbootstrapped_async()
.await?;
let client = std::sync::Arc::new(client);
let client_clone = client.clone();
tokio::spawn(async move {
match client_clone.bootstrap().await {
Ok(()) => {
info!("Tor client successfully bootstrapped");
}
Err(e) => {
tracing::error!("Failed to bootstrap Tor client: {}. Tor functionality will be unavailable.", e);
}
}
});
Some(client)
} else {
None
};
let config = Config::new_with_port_and_tor_client(
args.host, args.host,
args.port, args.port,
std::env::temp_dir().join("monero-rpc-pool"), std::env::temp_dir().join("monero-rpc-pool"),
tor_client,
); );
info!( info!(

View file

@ -1,6 +1,9 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tracing::{debug, warn}; use tracing::warn;
use typeshare::typeshare; use typeshare::typeshare;
use crate::database::Database; use crate::database::Database;
@ -16,6 +19,7 @@ pub struct PoolStatus {
#[typeshare(serialized_as = "number")] #[typeshare(serialized_as = "number")]
pub unsuccessful_health_checks: u64, pub unsuccessful_health_checks: u64,
pub top_reliable_nodes: Vec<ReliableNodeInfo>, pub top_reliable_nodes: Vec<ReliableNodeInfo>,
pub bandwidth_kb_per_sec: f64,
} }
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
@ -26,10 +30,69 @@ pub struct ReliableNodeInfo {
pub avg_latency_ms: Option<f64>, pub avg_latency_ms: Option<f64>,
} }
#[derive(Debug)]
struct BandwidthEntry {
timestamp: Instant,
bytes: u64,
}
#[derive(Debug)]
struct BandwidthTracker {
entries: VecDeque<BandwidthEntry>,
window_duration: Duration,
}
impl BandwidthTracker {
const WINDOW_DURATION: Duration = Duration::from_secs(60 * 3);
fn new() -> Self {
Self {
entries: VecDeque::new(),
window_duration: Self::WINDOW_DURATION,
}
}
fn record_bytes(&mut self, bytes: u64) {
let now = Instant::now();
self.entries.push_back(BandwidthEntry {
timestamp: now,
bytes,
});
// Clean up old entries
let cutoff = now - self.window_duration;
while let Some(front) = self.entries.front() {
if front.timestamp < cutoff {
self.entries.pop_front();
} else {
break;
}
}
}
fn get_kb_per_sec(&self) -> f64 {
if self.entries.len() < 5 {
return 0.0;
}
let total_bytes: u64 = self.entries.iter().map(|e| e.bytes).sum();
let now = Instant::now();
let oldest_time = self.entries.front().unwrap().timestamp;
let duration_secs = (now - oldest_time).as_secs_f64();
if duration_secs > 0.0 {
(total_bytes as f64 / 1024.0) / duration_secs
} else {
0.0
}
}
}
pub struct NodePool { pub struct NodePool {
db: Database, db: Database,
network: String, network: String,
status_sender: broadcast::Sender<PoolStatus>, status_sender: broadcast::Sender<PoolStatus>,
bandwidth_tracker: Arc<Mutex<BandwidthTracker>>,
} }
impl NodePool { impl NodePool {
@ -39,6 +102,7 @@ impl NodePool {
db, db,
network, network,
status_sender, status_sender,
bandwidth_tracker: Arc::new(Mutex::new(BandwidthTracker::new())),
}; };
(pool, status_receiver) (pool, status_receiver)
} }
@ -63,13 +127,19 @@ impl NodePool {
Ok(()) Ok(())
} }
pub fn record_bandwidth(&self, bytes: u64) {
if let Ok(mut tracker) = self.bandwidth_tracker.lock() {
tracker.record_bytes(bytes);
}
}
pub async fn publish_status_update(&self) -> Result<()> { pub async fn publish_status_update(&self) -> Result<()> {
let status = self.get_current_status().await?; let status = self.get_current_status().await?;
if let Err(e) = self.status_sender.send(status.clone()) { if let Err(e) = self.status_sender.send(status.clone()) {
warn!("Failed to send status update: {}", e); warn!("Failed to send status update: {}", e);
} else { } else {
debug!(?status, "Sent status update"); tracing::debug!(?status, "Sent status update");
} }
Ok(()) Ok(())
@ -81,6 +151,12 @@ impl NodePool {
let (successful_checks, unsuccessful_checks) = let (successful_checks, unsuccessful_checks) =
self.db.get_health_check_stats(&self.network).await?; self.db.get_health_check_stats(&self.network).await?;
let bandwidth_kb_per_sec = if let Ok(tracker) = self.bandwidth_tracker.lock() {
tracker.get_kb_per_sec()
} else {
0.0
};
let top_reliable_nodes = reliable_nodes let top_reliable_nodes = reliable_nodes
.into_iter() .into_iter()
.take(5) .take(5)
@ -97,6 +173,7 @@ impl NodePool {
successful_health_checks: successful_checks, successful_health_checks: successful_checks,
unsuccessful_health_checks: unsuccessful_checks, unsuccessful_health_checks: unsuccessful_checks,
top_reliable_nodes, top_reliable_nodes,
bandwidth_kb_per_sec,
}) })
} }
@ -105,9 +182,10 @@ impl NodePool {
pub async fn get_top_reliable_nodes(&self, limit: usize) -> Result<Vec<NodeAddress>> { pub async fn get_top_reliable_nodes(&self, limit: usize) -> Result<Vec<NodeAddress>> {
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
debug!( tracing::debug!(
"Getting top reliable nodes for network {} (target: {})", "Getting top reliable nodes for network {} (target: {})",
self.network, limit self.network,
limit
); );
let available_nodes = self let available_nodes = self
@ -149,7 +227,7 @@ impl NodePool {
selected_nodes.push(node); selected_nodes.push(node);
} }
debug!( tracing::debug!(
"Pool size: {} nodes for network {} (target: {})", "Pool size: {} nodes for network {} (target: {})",
selected_nodes.len(), selected_nodes.len(),
self.network, self.network,
@ -158,53 +236,4 @@ impl NodePool {
Ok(selected_nodes) Ok(selected_nodes)
} }
pub async fn get_pool_stats(&self) -> Result<PoolStats> {
let (total, reachable, reliable) = self.db.get_node_stats(&self.network).await?;
let reliable_nodes = self.db.get_reliable_nodes(&self.network).await?;
let avg_reliable_latency = if reliable_nodes.is_empty() {
None
} else {
let total_latency: f64 = reliable_nodes
.iter()
.filter_map(|node| node.health.avg_latency_ms)
.sum();
let count = reliable_nodes
.iter()
.filter(|node| node.health.avg_latency_ms.is_some())
.count();
if count > 0 {
Some(total_latency / count as f64)
} else {
None
}
};
Ok(PoolStats {
total_nodes: total,
reachable_nodes: reachable,
reliable_nodes: reliable,
avg_reliable_latency_ms: avg_reliable_latency,
})
}
}
#[derive(Debug)]
pub struct PoolStats {
pub total_nodes: i64,
pub reachable_nodes: i64,
pub reliable_nodes: i64,
pub avg_reliable_latency_ms: Option<f64>, // TOOD: Why is this an Option, we hate Options
}
impl PoolStats {
pub fn health_percentage(&self) -> f64 {
if self.total_nodes == 0 {
0.0
} else {
(self.reachable_nodes as f64 / self.total_nodes as f64) * 100.0
}
}
} }

File diff suppressed because it is too large Load diff

View file

@ -75,6 +75,12 @@ export default function MoneroPoolHealthBox() {
variant="outlined" variant="outlined"
size="small" size="small"
/> />
<Chip
label={`${poolStatus.bandwidth_kb_per_sec?.toFixed(1) ?? '0.0'} KB/s Bandwidth`}
color={poolStatus.bandwidth_kb_per_sec != null && poolStatus.bandwidth_kb_per_sec > 10 ? "info" : "default"}
variant="outlined"
size="small"
/>
</Box> </Box>
); );
}; };

View file

@ -38,6 +38,7 @@ import {
setFiatCurrency, setFiatCurrency,
setTheme, setTheme,
setTorEnabled, setTorEnabled,
setEnableMoneroTor,
setUseMoneroRpcPool, setUseMoneroRpcPool,
setDonateToDevelopment, setDonateToDevelopment,
} from "store/features/settingsSlice"; } from "store/features/settingsSlice";
@ -91,6 +92,7 @@ export default function SettingsBox() {
<Table> <Table>
<TableBody> <TableBody>
<TorSettings /> <TorSettings />
<MoneroTorSettings />
<DonationTipSetting /> <DonationTipSetting />
<ElectrumRpcUrlSetting /> <ElectrumRpcUrlSetting />
<MoneroRpcPoolSetting /> <MoneroRpcPoolSetting />
@ -715,6 +717,42 @@ export function TorSettings() {
); );
} }
/**
* A setting that allows you to enable or disable routing Monero wallet traffic through Tor.
* This setting is only visible when Tor is enabled.
*/
function MoneroTorSettings() {
const dispatch = useAppDispatch();
const torEnabled = useSettings((settings) => settings.enableTor);
const enableMoneroTor = useSettings((settings) => settings.enableMoneroTor);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) =>
dispatch(setEnableMoneroTor(event.target.checked));
// Hide this setting if Tor is disabled entirely
if (!torEnabled) {
return null;
}
return (
<TableRow>
<TableCell>
<SettingLabel
label="Route Monero traffic through Tor"
tooltip="When enabled, Monero wallet traffic will be routed through Tor for additional privacy. Requires main Tor setting to be enabled."
/>
</TableCell>
<TableCell>
<Switch
checked={enableMoneroTor}
onChange={handleChange}
color="primary"
/>
</TableCell>
</TableRow>
);
}
/** /**
* A setting that allows you to manage rendezvous points for maker discovery * A setting that allows you to manage rendezvous points for maker discovery
*/ */

View file

@ -1,14 +1,5 @@
import { import { Box, Typography, Card, LinearProgress } from "@mui/material";
Box, import { useAppSelector } from "store/hooks";
Typography,
CircularProgress,
Button,
Card,
CardContent,
Divider,
CardHeader,
LinearProgress,
} from "@mui/material";
import { PiconeroAmount } from "../../../other/Units"; import { PiconeroAmount } from "../../../other/Units";
import { FiatPiconeroAmount } from "../../../other/Units"; import { FiatPiconeroAmount } from "../../../other/Units";
import StateIndicator from "./StateIndicator"; import StateIndicator from "./StateIndicator";
@ -30,23 +21,77 @@ export default function WalletOverview({
balance, balance,
syncProgress, syncProgress,
}: WalletOverviewProps) { }: WalletOverviewProps) {
const lowestCurrentBlock = useAppSelector(
(state) => state.wallet.state.lowestCurrentBlock,
);
const poolStatus = useAppSelector((state) => state.pool.status);
const pendingBalance = const pendingBalance =
parseFloat(balance.total_balance) - parseFloat(balance.unlocked_balance); parseFloat(balance.total_balance) - parseFloat(balance.unlocked_balance);
const isSyncing = syncProgress && syncProgress.progress_percentage < 100; const isSyncing = syncProgress && syncProgress.progress_percentage < 100;
const blocksLeft = syncProgress?.target_block - syncProgress?.current_block; const blocksLeft = syncProgress?.target_block - syncProgress?.current_block;
// Treat blocksLeft = 1 as if we have no direct knowledge
const hasDirectKnowledge = blocksLeft != null && blocksLeft > 1;
// syncProgress.progress_percentage is not good to display
// assuming we have an old wallet, eventually we will always only use the last few cm of the progress bar
//
// We calculate our own progress percentage
// lowestCurrentBlock is the lowest block we have seen
// currentBlock is the current block we are on (how war we've synced)
// targetBlock is the target block we are syncing to
//
// The progressPercentage below is the progress on that path
// If the lowestCurrentBlock is null, we fallback to the syncProgress.progress_percentage
const progressPercentage =
lowestCurrentBlock === null || !syncProgress
? syncProgress?.progress_percentage || 0
: syncProgress.target_block === lowestCurrentBlock
? 100 // Fully synced when target equals lowest current block
: Math.max(
0,
Math.min(
100,
((syncProgress.current_block - lowestCurrentBlock) /
(syncProgress.target_block - lowestCurrentBlock)) *
100,
),
);
const isStuck = poolStatus?.bandwidth_kb_per_sec != null && poolStatus.bandwidth_kb_per_sec < 0.01;
// Calculate estimated time remaining for sync
const formatTimeRemaining = (seconds: number): string => {
if (seconds < 60) return `${Math.round(seconds)}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;
return `${Math.round(seconds / 86400)}d`;
};
const estimatedTimeRemaining =
hasDirectKnowledge && poolStatus?.bandwidth_kb_per_sec != null && poolStatus.bandwidth_kb_per_sec > 0
? (blocksLeft * 130) / poolStatus.bandwidth_kb_per_sec // blocks * 130kb / kb_per_sec = seconds
: null;
return ( return (
<Card sx={{ p: 2, position: "relative", borderRadius: 2 }} elevation={4}> <Card sx={{ p: 2, position: "relative", borderRadius: 2 }} elevation={4}>
{syncProgress && syncProgress.progress_percentage < 100 && ( {syncProgress && syncProgress.progress_percentage < 100 && (
<LinearProgress <LinearProgress
value={syncProgress.progress_percentage} value={hasDirectKnowledge ? progressPercentage : undefined}
variant="determinate" valueBuffer={
// If the bandwidth is low, we may not be making progress
// We don't show the buffer in this case
hasDirectKnowledge && !isStuck ? progressPercentage : undefined
}
variant={hasDirectKnowledge ? "buffer" : "indeterminate"}
sx={{ sx={{
width: "100%",
position: "absolute", position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
width: "100%",
}} }}
/> />
)} )}
@ -54,95 +99,105 @@ export default function WalletOverview({
{/* Balance */} {/* Balance */}
<Box <Box
sx={{ sx={{
display: "grid", display: "flex",
gridTemplateColumns: "1.5fr 1fr max-content", justifyContent: "space-between",
rowGap: 0.5, alignItems: "flex-start",
columnGap: 2,
mb: 1, mb: 1,
}} }}
> >
<Typography {/* Left side content */}
variant="body2"
color="text.secondary"
sx={{ mb: 1, gridColumn: "1", gridRow: "1" }}
>
Available Funds
</Typography>
<Typography variant="h4" sx={{ gridColumn: "1", gridRow: "2" }}>
<PiconeroAmount
amount={parseFloat(balance.unlocked_balance)}
fixedPrecision={4}
disableTooltip
/>
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ gridColumn: "1", gridRow: "3" }}
>
<FiatPiconeroAmount amount={parseFloat(balance.unlocked_balance)} />
</Typography>
{pendingBalance > 0 && (
<>
<Typography
variant="body2"
color="warning"
sx={{
mb: 1,
animation: "pulse 2s infinite",
gridColumn: "2",
gridRow: "1",
alignSelf: "end",
}}
>
Pending
</Typography>
<Typography
variant="h5"
sx={{ gridColumn: "2", gridRow: "2", alignSelf: "center" }}
>
<PiconeroAmount amount={pendingBalance} fixedPrecision={4} />
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ gridColumn: "2", gridRow: "3" }}
>
<FiatPiconeroAmount amount={pendingBalance} />
</Typography>
</>
)}
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "row",
alignItems: "flex-end", gap: 4,
}} }}
> >
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "column",
alignItems: "center", gap: 0.5,
gap: 1,
}} }}
> >
<Typography variant="body2"> <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{isSyncing ? "syncing" : "synced"} Available Funds
</Typography>
<Typography variant="h4">
<PiconeroAmount
amount={parseFloat(balance.unlocked_balance)}
fixedPrecision={4}
disableTooltip
/>
</Typography> </Typography>
<StateIndicator
color={isSyncing ? "primary" : "success"}
pulsating={isSyncing}
/>
</Box>
{isSyncing && (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{blocksLeft.toLocaleString()} blocks left <FiatPiconeroAmount
amount={parseFloat(balance.unlocked_balance)}
/>
</Typography> </Typography>
</Box>
{pendingBalance > 0 && (
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 0.5,
}}
>
<Typography
variant="body2"
color="warning"
sx={{
mb: 1,
animation: "pulse 2s infinite",
}}
>
Pending
</Typography>
<Typography variant="h5">
<PiconeroAmount amount={pendingBalance} fixedPrecision={4} />
</Typography>
<Typography variant="body2" color="text.secondary">
<FiatPiconeroAmount amount={pendingBalance} />
</Typography>
</Box>
)} )}
</Box> </Box>
{/* Right side - simple approach */}
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: 2,
}}
>
<StateIndicator
color={isSyncing ? "primary" : "success"}
pulsating={isSyncing}
/>
<Box sx={{ textAlign: "right" }}>
{isSyncing && hasDirectKnowledge && (
<Typography variant="body2" color="text.secondary">
{blocksLeft?.toLocaleString()} blocks left
</Typography>
)}
{poolStatus && isSyncing && !isStuck && (
<>
<Typography
variant="caption"
color="text.secondary"
sx={{ mt: 0.5, fontSize: "0.7rem", display: "block" }}
>
{estimatedTimeRemaining && !isStuck && (
<>{formatTimeRemaining(estimatedTimeRemaining)} left</>
)} / {poolStatus.bandwidth_kb_per_sec?.toFixed(1) ?? '0.0'} KB/s
</Typography>
</>
)}
</Box>
</Box>
</Box> </Box>
</Card> </Card>
); );

View file

@ -21,30 +21,29 @@ export default function WalletPageLoadingState() {
{/* Balance */} {/* Balance */}
<Box <Box
sx={{ sx={{
display: "grid", display: "flex",
gridTemplateColumns: "1.5fr 1fr max-content", justifyContent: "space-between",
rowGap: 0.5, alignItems: "flex-start",
columnGap: 2,
mb: 1, mb: 1,
}} }}
> >
<Typography <Box
variant="body2" sx={{
color="text.secondary" display: "flex",
sx={{ mb: 1, gridColumn: "1", gridRow: "1" }} flexDirection: "column",
gap: 0.5,
}}
> >
Available Funds <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
</Typography> Available Funds
<Typography variant="h4" sx={{ gridColumn: "1", gridRow: "2" }}> </Typography>
<Skeleton variant="text" width="80%" /> <Typography variant="h4">
</Typography> <Skeleton variant="text" width="80%" />
<Typography </Typography>
variant="body2" <Typography variant="body2" color="text.secondary">
color="text.secondary" <Skeleton variant="text" width="40%" />
sx={{ gridColumn: "1", gridRow: "3" }} </Typography>
> </Box>
<Skeleton variant="text" width="40%" />
</Typography>
<Box <Box
sx={{ sx={{
@ -61,7 +60,6 @@ export default function WalletPageLoadingState() {
gap: 1, gap: 1,
}} }}
> >
<Typography variant="body2">loading</Typography>
<StateIndicator color="primary" pulsating={true} /> <StateIndicator color="primary" pulsating={true} />
</Box> </Box>
</Box> </Box>

View file

@ -319,6 +319,8 @@ export async function initializeContext() {
// For Monero nodes, determine whether to use pool or custom node // For Monero nodes, determine whether to use pool or custom node
const useMoneroRpcPool = store.getState().settings.useMoneroRpcPool; const useMoneroRpcPool = store.getState().settings.useMoneroRpcPool;
const useMoneroTor = store.getState().settings.enableMoneroTor;
const moneroNodeUrl = const moneroNodeUrl =
store.getState().settings.nodes[network][Blockchain.Monero][0] ?? null; store.getState().settings.nodes[network][Blockchain.Monero][0] ?? null;
@ -341,6 +343,7 @@ export async function initializeContext() {
electrum_rpc_urls: bitcoinNodes, electrum_rpc_urls: bitcoinNodes,
monero_node_config: moneroNodeConfig, monero_node_config: moneroNodeConfig,
use_tor: useTor, use_tor: useTor,
enable_monero_tor: useMoneroTor,
}; };
logger.info("Initializing context with settings", tauriSettings); logger.info("Initializing context with settings", tauriSettings);

View file

@ -19,6 +19,8 @@ export interface SettingsState {
fiatCurrency: FiatCurrency; fiatCurrency: FiatCurrency;
/// Whether to enable Tor for p2p connections /// Whether to enable Tor for p2p connections
enableTor: boolean; enableTor: boolean;
/// Whether to route Monero wallet traffic through Tor
enableMoneroTor: boolean;
/// Whether to use the Monero RPC pool for load balancing (true) or custom nodes (false) /// Whether to use the Monero RPC pool for load balancing (true) or custom nodes (false)
useMoneroRpcPool: boolean; useMoneroRpcPool: boolean;
userHasSeenIntroduction: boolean; userHasSeenIntroduction: boolean;
@ -126,6 +128,7 @@ const initialState: SettingsState = {
fetchFiatPrices: false, fetchFiatPrices: false,
fiatCurrency: FiatCurrency.Usd, fiatCurrency: FiatCurrency.Usd,
enableTor: true, enableTor: true,
enableMoneroTor: false, // Default to not routing Monero traffic through Tor
useMoneroRpcPool: true, // Default to using RPC pool useMoneroRpcPool: true, // Default to using RPC pool
userHasSeenIntroduction: false, userHasSeenIntroduction: false,
rendezvousPoints: DEFAULT_RENDEZVOUS_POINTS, rendezvousPoints: DEFAULT_RENDEZVOUS_POINTS,
@ -215,6 +218,9 @@ const alertsSlice = createSlice({
setTorEnabled(slice, action: PayloadAction<boolean>) { setTorEnabled(slice, action: PayloadAction<boolean>) {
slice.enableTor = action.payload; slice.enableTor = action.payload;
}, },
setEnableMoneroTor(slice, action: PayloadAction<boolean>) {
slice.enableMoneroTor = action.payload;
},
setUseMoneroRpcPool(slice, action: PayloadAction<boolean>) { setUseMoneroRpcPool(slice, action: PayloadAction<boolean>) {
slice.useMoneroRpcPool = action.payload; slice.useMoneroRpcPool = action.payload;
}, },
@ -236,6 +242,7 @@ export const {
setFetchFiatPrices, setFetchFiatPrices,
setFiatCurrency, setFiatCurrency,
setTorEnabled, setTorEnabled,
setEnableMoneroTor,
setUseMoneroRpcPool, setUseMoneroRpcPool,
setUserHasSeenIntroduction, setUserHasSeenIntroduction,
addRendezvousPoint, addRendezvousPoint,

View file

@ -11,6 +11,7 @@ interface WalletState {
balance: GetMoneroBalanceResponse | null; balance: GetMoneroBalanceResponse | null;
syncProgress: GetMoneroSyncProgressResponse | null; syncProgress: GetMoneroSyncProgressResponse | null;
history: GetMoneroHistoryResponse | null; history: GetMoneroHistoryResponse | null;
lowestCurrentBlock: number | null;
} }
export interface WalletSlice { export interface WalletSlice {
@ -24,6 +25,7 @@ const initialState: WalletSlice = {
balance: null, balance: null,
syncProgress: null, syncProgress: null,
history: null, history: null,
lowestCurrentBlock: null,
}, },
}; };
@ -42,6 +44,16 @@ export const walletSlice = createSlice({
slice, slice,
action: PayloadAction<GetMoneroSyncProgressResponse>, action: PayloadAction<GetMoneroSyncProgressResponse>,
) { ) {
slice.state.lowestCurrentBlock = Math.min(
// We ignore anything below 10 blocks as this may be something like wallet2
// sending a wrong value when it hasn't initialized yet
slice.state.lowestCurrentBlock < 10 ||
slice.state.lowestCurrentBlock === null
? Infinity
: slice.state.lowestCurrentBlock,
action.payload.current_block,
);
slice.state.syncProgress = action.payload; slice.state.syncProgress = action.payload;
}, },
setHistory(slice, action: PayloadAction<GetMoneroHistoryResponse>) { setHistory(slice, action: PayloadAction<GetMoneroHistoryResponse>) {

View file

@ -440,6 +440,7 @@ async fn initialize_context(
.with_json(false) .with_json(false)
.with_debug(true) .with_debug(true)
.with_tor(settings.use_tor) .with_tor(settings.use_tor)
.with_enable_monero_tor(settings.enable_monero_tor)
.with_tauri(tauri_handle.clone()) .with_tauri(tauri_handle.clone())
.build() .build()
.await; .await;

View file

@ -25,7 +25,7 @@ use structopt::clap;
use structopt::clap::ErrorKind; use structopt::clap::ErrorKind;
use swap::asb::command::{parse_args, Arguments, Command}; use swap::asb::command::{parse_args, Arguments, Command};
use swap::asb::{cancel, punish, redeem, refund, safely_abort, EventLoop, Finality, KrakenRate}; use swap::asb::{cancel, punish, redeem, refund, safely_abort, EventLoop, Finality, KrakenRate};
use swap::common::tor::init_tor_client; use swap::common::tor::{bootstrap_tor_client, create_tor_client};
use swap::common::tracing_util::Format; use swap::common::tracing_util::Format;
use swap::common::{self, get_logs, warn_if_outdated}; use swap::common::{self, get_logs, warn_if_outdated};
use swap::database::{open_db, AccessMode}; use swap::database::{open_db, AccessMode};
@ -201,8 +201,10 @@ pub async fn main() -> Result<()> {
let kraken_rate = KrakenRate::new(config.maker.ask_spread, kraken_price_updates); let kraken_rate = KrakenRate::new(config.maker.ask_spread, kraken_price_updates);
let namespace = XmrBtcNamespace::from_is_testnet(testnet); let namespace = XmrBtcNamespace::from_is_testnet(testnet);
// Initialize Tor client // Initialize and bootstrap Tor client
let tor_client = init_tor_client(&config.data.dir, None).await?.into(); let tor_client = create_tor_client(&config.data.dir).await?;
bootstrap_tor_client(tor_client.clone(), None).await?;
let tor_client = tor_client.into();
let (mut swarm, onion_addresses) = swarm::asb( let (mut swarm, onion_addresses) = swarm::asb(
&seed, &seed,
@ -495,10 +497,7 @@ async fn init_monero_wallet(
let (server_info, _status_receiver, _pool_handle) = let (server_info, _status_receiver, _pool_handle) =
monero_rpc_pool::start_server_with_random_port( monero_rpc_pool::start_server_with_random_port(
monero_rpc_pool::config::Config::new_random_port( monero_rpc_pool::config::Config::new_random_port(config.data.dir.join("monero-rpc-pool")),
"127.0.0.1".to_string(),
config.data.dir.join("monero-rpc-pool"),
),
env_config.monero_network, env_config.monero_network,
) )
.await .await

View file

@ -3,7 +3,7 @@ pub mod tauri_bindings;
use crate::cli::api::tauri_bindings::SeedChoice; use crate::cli::api::tauri_bindings::SeedChoice;
use crate::cli::command::{Bitcoin, Monero}; use crate::cli::command::{Bitcoin, Monero};
use crate::common::tor::init_tor_client; use crate::common::tor::{bootstrap_tor_client, create_tor_client};
use crate::common::tracing_util::Format; use crate::common::tracing_util::Format;
use crate::database::{open_db, AccessMode}; use crate::database::{open_db, AccessMode};
use crate::network::rendezvous::XmrBtcNamespace; use crate::network::rendezvous::XmrBtcNamespace;
@ -204,6 +204,7 @@ pub struct ContextBuilder {
debug: bool, debug: bool,
json: bool, json: bool,
tor: bool, tor: bool,
enable_monero_tor: bool,
tauri_handle: Option<TauriHandle>, tauri_handle: Option<TauriHandle>,
} }
@ -227,6 +228,7 @@ impl ContextBuilder {
debug: false, debug: false,
json: false, json: false,
tor: false, tor: false,
enable_monero_tor: false,
tauri_handle: None, tauri_handle: None,
} }
} }
@ -280,6 +282,12 @@ impl ContextBuilder {
self self
} }
/// Whether to route Monero wallet traffic through Tor (default false)
pub fn with_enable_monero_tor(mut self, enable_monero_tor: bool) -> Self {
self.enable_monero_tor = enable_monero_tor;
self
}
/// Takes the builder, initializes the context by initializing the wallets and other components and returns the Context. /// Takes the builder, initializes the context by initializing the wallets and other components and returns the Context.
pub async fn build(self) -> Result<Context> { pub async fn build(self) -> Result<Context> {
// This is the data directory for the eigenwallet (wallet files) // This is the data directory for the eigenwallet (wallet files)
@ -314,12 +322,29 @@ impl ContextBuilder {
); );
}); });
// Start the rpc pool for the monero wallet // Create unbootstrapped Tor client early if enabled
let unbootstrapped_tor_client = if self.tor {
match create_tor_client(&base_data_dir).await.inspect_err(|err| {
tracing::warn!(%err, "Failed to create Tor client. We will continue without Tor");
}) {
Ok(client) => Some(client),
Err(_) => None,
}
} else {
tracing::warn!("Internal Tor client not enabled, skipping initialization");
None
};
// Start the rpc pool for the monero wallet with optional Tor client based on enable_monero_tor setting
let (server_info, mut status_receiver, pool_handle) = let (server_info, mut status_receiver, pool_handle) =
monero_rpc_pool::start_server_with_random_port( monero_rpc_pool::start_server_with_random_port(
monero_rpc_pool::config::Config::new_random_port( monero_rpc_pool::config::Config::new_random_port_with_tor_client(
"127.0.0.1".to_string(),
base_data_dir.join("monero-rpc-pool"), base_data_dir.join("monero-rpc-pool"),
if self.enable_monero_tor {
unbootstrapped_tor_client.clone()
} else {
None
},
), ),
match self.is_testnet { match self.is_testnet {
true => crate::monero::Network::Stagenet, true => crate::monero::Network::Stagenet,
@ -460,25 +485,25 @@ impl ContextBuilder {
} }
}; };
let initialize_tor_client = async { let bootstrap_tor_client_task = async {
// Don't init a tor client unless we should use it. // Bootstrap the Tor client if we have one
if !self.tor { match unbootstrapped_tor_client.clone() {
tracing::warn!("Internal Tor client not enabled, skipping initialization"); Some(tor_client) => {
return Ok(None); bootstrap_tor_client(tor_client.clone(), tauri_handle.clone())
.await
.inspect_err(|err| {
tracing::warn!(%err, "Failed to bootstrap Tor client. It will remain unbootstrapped");
})
.ok();
Ok(Some(tor_client))
}
None => Ok(None),
} }
let maybe_tor_client = init_tor_client(&data_dir, tauri_handle.clone())
.await
.inspect_err(|err| {
tracing::warn!(%err, "Failed to create Tor client. We will continue without Tor");
})
.ok();
Ok(maybe_tor_client)
}; };
let (bitcoin_wallet, tor) = let (bitcoin_wallet, tor) =
tokio::try_join!(initialize_bitcoin_wallet, initialize_tor_client,)?; tokio::try_join!(initialize_bitcoin_wallet, bootstrap_tor_client_task,)?;
// If we have a bitcoin wallet and a tauri handle, we start a background task // If we have a bitcoin wallet and a tauri handle, we start a background task
if let Some(wallet) = bitcoin_wallet.clone() { if let Some(wallet) = bitcoin_wallet.clone() {

View file

@ -1008,6 +1008,8 @@ pub struct TauriSettings {
pub electrum_rpc_urls: Vec<String>, pub electrum_rpc_urls: Vec<String>,
/// Whether to initialize and use a tor client. /// Whether to initialize and use a tor client.
pub use_tor: bool, pub use_tor: bool,
/// Whether to route Monero wallet traffic through Tor
pub enable_monero_tor: bool,
} }
#[typeshare] #[typeshare]

View file

@ -8,9 +8,9 @@ use arti_client::{config::TorClientConfigBuilder, status::BootstrapStatus, Error
use futures::StreamExt; use futures::StreamExt;
use tor_rtcompat::tokio::TokioRustlsRuntime; use tor_rtcompat::tokio::TokioRustlsRuntime;
pub async fn init_tor_client( /// Creates an unbootstrapped Tor client
pub async fn create_tor_client(
data_dir: &Path, data_dir: &Path,
tauri_handle: Option<TauriHandle>,
) -> Result<Arc<TorClient<TokioRustlsRuntime>>, Error> { ) -> Result<Arc<TorClient<TokioRustlsRuntime>>, Error> {
// We store the Tor state in the data directory // We store the Tor state in the data directory
let data_dir = data_dir.join("tor"); let data_dir = data_dir.join("tor");
@ -23,20 +23,28 @@ pub async fn init_tor_client(
.build() .build()
.expect("We initialized the Tor client all required attributes"); .expect("We initialized the Tor client all required attributes");
// Start the Arti client, and let it bootstrap a connection to the Tor network. // Create the Arti client without bootstrapping
// (This takes a while to gather the necessary directory information.
// It uses cached information when possible.)
let runtime = TokioRustlsRuntime::current().expect("We are always running with tokio"); let runtime = TokioRustlsRuntime::current().expect("We are always running with tokio");
tracing::debug!("Bootstrapping Tor client"); tracing::debug!("Creating unbootstrapped Tor client");
let tor_client = TorClient::with_runtime(runtime) let tor_client = TorClient::with_runtime(runtime)
.config(config) .config(config)
.create_unbootstrapped_async() .create_unbootstrapped_async()
.await?; .await?;
Ok(Arc::new(tor_client))
}
/// Bootstraps an existing Tor client
pub async fn bootstrap_tor_client(
tor_client: Arc<TorClient<TokioRustlsRuntime>>,
tauri_handle: Option<TauriHandle>,
) -> Result<(), Error> {
let mut bootstrap_events = tor_client.bootstrap_events(); let mut bootstrap_events = tor_client.bootstrap_events();
tracing::debug!("Bootstrapping Tor client");
// Create a background progress handle for the Tor bootstrap process // Create a background progress handle for the Tor bootstrap process
// The handle manages the TauriHandle internally, so we don't need to worry about it anymore // The handle manages the TauriHandle internally, so we don't need to worry about it anymore
let progress_handle = let progress_handle =
@ -67,7 +75,7 @@ pub async fn init_tor_client(
}, },
}?; }?;
Ok(Arc::new(tor_client)) Ok(())
} }
// A trait to convert the Tor bootstrap event into a TauriBootstrapStatus // A trait to convert the Tor bootstrap event into a TauriBootstrapStatus