From 7e6138570f40f71296ec1e880cc165245b34332e Mon Sep 17 00:00:00 2001 From: Mohan <86064887+binarybaron@users.noreply.github.com> Date: Mon, 11 Aug 2025 10:34:40 +0200 Subject: [PATCH] feat(gui): Change Monero node while running (#502) * feat(gui): Change daemon during runtime * feat(swap-controller): Add `monero-seed` RPC command * nitpicks * amend changelog --- CHANGELOG.md | 3 + monero-rpc-pool/src/lib.rs | 17 +++++- monero-sys/src/bridge.h | 5 +- monero-sys/src/bridge.rs | 6 +- monero-sys/src/lib.rs | 11 ++-- src-gui/src/renderer/rpc.ts | 31 ++++++++++ src-gui/src/store/middleware/storeListener.ts | 27 ++++++++- src-tauri/src/lib.rs | 12 ++-- swap/src/cli/api.rs | 59 +++++++++++++++++++ swap/src/cli/api/request.rs | 36 ++++++++++- swap/src/monero/wallet.rs | 23 +++++++- 11 files changed, 213 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aae7bcc0..a62211a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- ASB + CONTROLLER: Add a `monero_seed` command to the controller shell. You can use it to export the seed and restore height of the internal Monero wallet. You can use those to import the wallet into a wallet software of your own choosing. +- GUI: You can now change the Monero Node without having to restart. + ## [3.0.0-beta.8] - 2025-08-10 - GUI: Speedup startup by concurrently bootstrapping Tor and requesting the user to select a wallet diff --git a/monero-rpc-pool/src/lib.rs b/monero-rpc-pool/src/lib.rs index 594df9e5..01d1089d 100644 --- a/monero-rpc-pool/src/lib.rs +++ b/monero-rpc-pool/src/lib.rs @@ -37,6 +37,14 @@ pub struct AppState { /// Manages background tasks for the RPC pool pub struct PoolHandle { pub status_update_handle: JoinHandle<()>, + pub server_info: ServerInfo, +} + +impl PoolHandle { + /// Get the current server info for the pool + pub fn server_info(&self) -> &ServerInfo { + &self.server_info + } } impl Drop for PoolHandle { @@ -92,6 +100,10 @@ pub async fn create_app_with_receiver( let pool_handle = PoolHandle { status_update_handle, + server_info: ServerInfo { + port: config.port, + host: config.host.clone(), + }, }; let app_state = AppState { @@ -145,7 +157,7 @@ pub async fn start_server_with_random_port( PoolHandle, )> { let host = config.host.clone(); - let (app, status_receiver, pool_handle) = create_app_with_receiver(config).await?; + let (app, status_receiver, mut pool_handle) = create_app_with_receiver(config).await?; // Bind to port 0 to get a random available port let listener = tokio::net::TcpListener::bind(format!("{}:0", host)).await?; @@ -156,6 +168,9 @@ pub async fn start_server_with_random_port( host: host.clone(), }; + // Update the pool handle with the actual server info + pool_handle.server_info = server_info.clone(); + info!( "Started server on {}:{} (random port)", server_info.host, server_info.port diff --git a/monero-sys/src/bridge.h b/monero-sys/src/bridge.h index 4bc2d739..e3b7142d 100644 --- a/monero-sys/src/bridge.h +++ b/monero-sys/src/bridge.h @@ -200,9 +200,10 @@ namespace Monero subtract_fee_indices); // Subtract fee from all outputs } - inline bool setWalletDaemon(Wallet &wallet, const std::string &daemon_address) + inline bool setWalletDaemon(Wallet &wallet, const std::string &daemon_address, bool try_ssl) { - return wallet.setDaemon(daemon_address); + std::string ssl = try_ssl ? "autodetect" : "disabled"; + return wallet.setDaemon(daemon_address, ssl); } inline std::unique_ptr pendingTransactionTxId(const PendingTransaction &tx) diff --git a/monero-sys/src/bridge.rs b/monero-sys/src/bridge.rs index a00c88af..dbc0ab2d 100644 --- a/monero-sys/src/bridge.rs +++ b/monero-sys/src/bridge.rs @@ -191,7 +191,11 @@ pub mod ffi { fn refreshAsync(self: Pin<&mut Wallet>) -> Result<()>; /// Set the daemon address. - fn setWalletDaemon(wallet: Pin<&mut Wallet>, daemon_address: &CxxString) -> Result; + fn setWalletDaemon( + wallet: Pin<&mut Wallet>, + daemon_address: &CxxString, + try_ssl: bool, + ) -> Result; /// Set whether the daemon is trusted. fn setTrustedDaemon(self: Pin<&mut Wallet>, trusted: bool) -> Result<()>; diff --git a/monero-sys/src/lib.rs b/monero-sys/src/lib.rs index ef80f7ab..e0e42939 100644 --- a/monero-sys/src/lib.rs +++ b/monero-sys/src/lib.rs @@ -1394,7 +1394,7 @@ impl FfiWallet { .map_err(|e| anyhow!("Failed to initialize wallet: {e}"))?; tracing::debug!("Initialized wallet, setting daemon address"); - wallet.set_daemon_address(&daemon.address)?; + wallet.set_daemon(&daemon)?; if background_sync { tracing::debug!("Background sync enabled, starting refresh thread"); @@ -1438,13 +1438,16 @@ impl FfiWallet { monero::Address::from_str(&address.to_string()).expect("wallet's own address to be valid") } - pub fn set_daemon_address(&mut self, address: &str) -> anyhow::Result<()> { - tracing::debug!(%address, "Setting daemon address"); + pub fn set_daemon(&mut self, daemon: &Daemon) -> anyhow::Result<()> { + let ssl = daemon.ssl; + let address = daemon.address.clone(); + + tracing::debug!(%address, %ssl, "Setting daemon address"); let_cxx_string!(address = address); let raw_wallet = &mut self.inner; - let success = ffi::setWalletDaemon(raw_wallet.pinned(), &address) + let success = ffi::setWalletDaemon(raw_wallet.pinned(), &address, ssl) .context("Failed to set daemon address: FFI call failed with exception")?; if !success { diff --git a/src-gui/src/renderer/rpc.ts b/src-gui/src/renderer/rpc.ts index bed3db42..3ffbf873 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -44,6 +44,7 @@ import { SetRestoreHeightArgs, SetRestoreHeightResponse, GetRestoreHeightResponse, + MoneroNodeConfig, } from "models/tauriModel"; import { rpcSetBalance, @@ -641,3 +642,33 @@ export async function saveFilesInDialog(files: Record) { export async function dfxAuthenticate(): Promise { return await invokeNoArgs("dfx_authenticate"); } + +export async function changeMoneroNode( + nodeConfig: MoneroNodeConfig, +): Promise { + await invoke<{ node_config: MoneroNodeConfig }, void>("change_monero_node", { + node_config: nodeConfig, + }); +} + +// Helper function to create MoneroNodeConfig from current settings +export async function getCurrentMoneroNodeConfig(): Promise { + const network = getNetwork(); + const useMoneroRpcPool = store.getState().settings.useMoneroRpcPool; + const moneroNodeUrl = + store.getState().settings.nodes[network][Blockchain.Monero][0] ?? null; + + const moneroNodeConfig = + useMoneroRpcPool || + moneroNodeUrl == null || + !(await getMoneroNodeStatus(moneroNodeUrl, network)) + ? { type: "Pool" as const } + : { + type: "SingleNode" as const, + content: { + url: moneroNodeUrl, + }, + }; + + return moneroNodeConfig; +} diff --git a/src-gui/src/store/middleware/storeListener.ts b/src-gui/src/store/middleware/storeListener.ts index 9042c5a9..de2e9818 100644 --- a/src-gui/src/store/middleware/storeListener.ts +++ b/src-gui/src/store/middleware/storeListener.ts @@ -7,6 +7,8 @@ import { fetchSellersAtPresetRendezvousPoints, getSwapInfo, initializeMoneroWallet, + changeMoneroNode, + getCurrentMoneroNodeConfig, } from "renderer/rpc"; import logger from "utils/logger"; import { contextStatusEventReceived } from "store/features/rpcSlice"; @@ -14,6 +16,9 @@ import { addNode, setFetchFiatPrices, setFiatCurrency, + setUseMoneroRpcPool, + Blockchain, + Network, } from "store/features/settingsSlice"; import { fetchFeedbackMessagesViaHttp, updateRates } from "renderer/api"; import { store } from "renderer/store/storeRenderer"; @@ -22,7 +27,8 @@ import { addFeedbackId, setConversation, } from "store/features/conversationsSlice"; -import { TauriContextStatusEvent } from "models/tauriModel"; +import { TauriContextStatusEvent, MoneroNodeConfig } from "models/tauriModel"; +import { getNetwork } from "store/config"; // Create a Map to store throttled functions per swap_id const throttledGetSwapInfoFunctions = new Map< @@ -128,6 +134,25 @@ export function createMainListeners() { }, }); + // Listener for Monero node configuration changes + listener.startListening({ + actionCreator: setUseMoneroRpcPool, + effect: async (action) => { + const usePool = action.payload; + logger.info( + `Monero node setting changed to: ${usePool ? "Pool" : "Single Node"}`, + ); + + try { + const nodeConfig = await getCurrentMoneroNodeConfig(); + await changeMoneroNode(nodeConfig); + logger.info("Changed Monero node configuration to: ", nodeConfig); + } catch (error) { + logger.error("Failed to change Monero node configuration:", error); + } + }, + }); + // Listener for when a feedback id is added listener.startListening({ actionCreator: addFeedbackId, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4cd8c394..c24d94fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,11 +6,11 @@ use swap::cli::{ api::{ data, request::{ - BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, CheckElectrumNodeArgs, - CheckElectrumNodeResponse, CheckMoneroNodeArgs, CheckMoneroNodeResponse, CheckSeedArgs, - CheckSeedResponse, DfxAuthenticateResponse, ExportBitcoinWalletArgs, - GetCurrentSwapArgs, GetDataDirArgs, GetHistoryArgs, GetLogsArgs, - GetMoneroAddressesArgs, GetMoneroBalanceArgs, GetMoneroHistoryArgs, + BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, ChangeMoneroNodeArgs, + CheckElectrumNodeArgs, CheckElectrumNodeResponse, CheckMoneroNodeArgs, + CheckMoneroNodeResponse, CheckSeedArgs, CheckSeedResponse, DfxAuthenticateResponse, + ExportBitcoinWalletArgs, GetCurrentSwapArgs, GetDataDirArgs, GetHistoryArgs, + GetLogsArgs, GetMoneroAddressesArgs, GetMoneroBalanceArgs, GetMoneroHistoryArgs, GetMoneroMainAddressArgs, GetMoneroSyncProgressArgs, GetPendingApprovalsResponse, GetRestoreHeightArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs, RejectApprovalArgs, RejectApprovalResponse, @@ -212,6 +212,7 @@ pub fn run() { reject_approval_request, get_restore_height, dfx_authenticate, + change_monero_node, ]) .setup(setup) .build(tauri::generate_context!()) @@ -253,6 +254,7 @@ tauri_command!(list_sellers, ListSellersArgs); tauri_command!(cancel_and_refund, CancelAndRefundArgs); tauri_command!(redact, RedactArgs); tauri_command!(send_monero, SendMoneroArgs); +tauri_command!(change_monero_node, ChangeMoneroNodeArgs); // These commands require no arguments tauri_command!(get_wallet_descriptor, ExportBitcoinWalletArgs, no_args); diff --git a/swap/src/cli/api.rs b/swap/src/cli/api.rs index ac37e2f1..def48130 100644 --- a/swap/src/cli/api.rs +++ b/swap/src/cli/api.rs @@ -644,6 +644,65 @@ impl Context { pub fn tauri_handle(&self) -> Option { self.tauri_handle.clone() } + + /// Change the Monero node configuration for all wallets + pub async fn change_monero_node(&self, node_config: MoneroNodeConfig) -> Result<()> { + let monero_manager = self + .monero_manager + .as_ref() + .context("Monero wallet manager not available")?; + + // Determine the daemon configuration based on the node config + let daemon = match node_config { + MoneroNodeConfig::Pool => { + // Use the pool handle to get server info + let pool_handle = self + .monero_rpc_pool_handle + .as_ref() + .context("Pool handle not available")?; + + let server_info = pool_handle.server_info(); + let pool_url: String = server_info.clone().into(); + tracing::info!("Switching to Monero RPC pool: {}", pool_url); + + monero_sys::Daemon { + address: pool_url, + ssl: false, + } + } + MoneroNodeConfig::SingleNode { url } => { + tracing::info!("Switching to single Monero node: {}", url); + + // Parse the URL to determine SSL and address + let (address, ssl) = if url.starts_with("https://") { + ( + url.strip_prefix("https://").unwrap_or(&url).to_string(), + true, + ) + } else if url.starts_with("http://") { + ( + url.strip_prefix("http://").unwrap_or(&url).to_string(), + false, + ) + } else { + // Default to HTTP if no protocol specified + (url, false) + }; + + monero_sys::Daemon { address, ssl } + } + }; + + // Update the wallet manager's daemon configuration + monero_manager + .change_monero_node(daemon.clone()) + .await + .context("Failed to change Monero node in wallet manager")?; + + tracing::info!(?daemon, "Switched Monero node"); + + Ok(()) + } } impl fmt::Debug for Context { diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index d98e0a2e..793ed8f5 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -1,7 +1,7 @@ use super::tauri_bindings::TauriHandle; use crate::bitcoin::{wallet, CancelTimelock, ExpiredTimelocks, PunishTimelock}; use crate::cli::api::tauri_bindings::{ - ApprovalRequestType, SelectMakerDetails, SendMoneroDetails, TauriEmitter, + ApprovalRequestType, MoneroNodeConfig, SelectMakerDetails, SendMoneroDetails, TauriEmitter, TauriSwapProgressEvent, }; use crate::cli::api::Context; @@ -2006,3 +2006,37 @@ pub struct DfxAuthenticateResponse { pub access_token: String, pub kyc_url: String, } + +// ChangeMoneroNode +#[typeshare] +#[derive(Debug, Serialize, Deserialize)] +pub struct ChangeMoneroNodeArgs { + pub node_config: MoneroNodeConfig, +} + +#[typeshare] +#[derive(Serialize, Deserialize, Debug)] +pub struct ChangeMoneroNodeResponse { + pub success: bool, +} + +impl Request for ChangeMoneroNodeArgs { + type Response = ChangeMoneroNodeResponse; + + async fn request(self, ctx: Arc) -> Result { + change_monero_node(self, ctx).await + } +} + +#[tracing::instrument(fields(method = "change_monero_node"), skip(context))] +pub async fn change_monero_node( + args: ChangeMoneroNodeArgs, + context: Arc, +) -> Result { + context + .change_monero_node(args.node_config) + .await + .context("Failed to change Monero node")?; + + Ok(ChangeMoneroNodeResponse { success: true }) +} diff --git a/swap/src/monero/wallet.rs b/swap/src/monero/wallet.rs index c4709d8b..152aca91 100644 --- a/swap/src/monero/wallet.rs +++ b/swap/src/monero/wallet.rs @@ -12,6 +12,7 @@ use anyhow::{Context, Result}; use monero::{Address, Network}; use monero_sys::WalletEventListener; pub use monero_sys::{Daemon, WalletHandle as Wallet, WalletHandleListener}; +use tokio::sync::RwLock; use uuid::Uuid; use crate::cli::api::{ @@ -29,7 +30,7 @@ pub struct Wallets { /// The network we're on. network: Network, /// The monero node we connect to. - daemon: Daemon, + daemon: Arc>, /// Keep the main wallet open and synced. main_wallet: Arc, /// Since Network::Regtest isn't a thing we have to use an extra flag. @@ -275,6 +276,8 @@ impl Wallets { .await; } + let daemon = Arc::new(RwLock::new(daemon)); + let wallets = Self { wallet_dir, network, @@ -323,6 +326,8 @@ impl Wallets { .await; } + let daemon = Arc::new(RwLock::new(daemon)); + let wallets = Self { wallet_dir, network, @@ -367,6 +372,8 @@ impl Wallets { .await .context("Couldn't fetch blockchain height")?; + let daemon = self.daemon.read().await.clone(); + let wallet = Wallet::open_or_create_from_keys( wallet_path.clone(), None, @@ -376,7 +383,7 @@ impl Wallets { spend_key, blockheight, false, // We don't sync the swap wallet, just import the transaction - self.daemon.clone(), + daemon, ) .await .context(format!( @@ -460,6 +467,18 @@ impl Wallets { }) } + pub async fn change_monero_node(&self, new_daemon: Daemon) -> Result<()> { + { + let mut daemon = self.daemon.write().await; + *daemon = new_daemon.clone(); + } + + self.main_wallet + .call(move |wallet| wallet.set_daemon(&new_daemon)) + .await?; + Ok(()) + } + /// Get the last 5 recently used wallets pub async fn get_recent_wallets(&self) -> Result> { if let Some(db) = &self.wallet_database {