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
This commit is contained in:
Mohan 2025-08-11 10:34:40 +02:00 committed by GitHub
parent 6861f38f16
commit 7e6138570f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 213 additions and 17 deletions

View file

@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [3.0.0-beta.8] - 2025-08-10
- GUI: Speedup startup by concurrently bootstrapping Tor and requesting the user to select a wallet - GUI: Speedup startup by concurrently bootstrapping Tor and requesting the user to select a wallet

View file

@ -37,6 +37,14 @@ pub struct AppState {
/// Manages background tasks for the RPC pool /// Manages background tasks for the RPC pool
pub struct PoolHandle { pub struct PoolHandle {
pub status_update_handle: JoinHandle<()>, 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 { impl Drop for PoolHandle {
@ -92,6 +100,10 @@ pub async fn create_app_with_receiver(
let pool_handle = PoolHandle { let pool_handle = PoolHandle {
status_update_handle, status_update_handle,
server_info: ServerInfo {
port: config.port,
host: config.host.clone(),
},
}; };
let app_state = AppState { let app_state = AppState {
@ -145,7 +157,7 @@ pub async fn start_server_with_random_port(
PoolHandle, PoolHandle,
)> { )> {
let host = config.host.clone(); 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 // 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?;
@ -156,6 +168,9 @@ pub async fn start_server_with_random_port(
host: host.clone(), host: host.clone(),
}; };
// Update the pool handle with the actual server info
pool_handle.server_info = server_info.clone();
info!( info!(
"Started server on {}:{} (random port)", "Started server on {}:{} (random port)",
server_info.host, server_info.port server_info.host, server_info.port

View file

@ -200,9 +200,10 @@ namespace Monero
subtract_fee_indices); // Subtract fee from all outputs 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<std::string> pendingTransactionTxId(const PendingTransaction &tx) inline std::unique_ptr<std::string> pendingTransactionTxId(const PendingTransaction &tx)

View file

@ -191,7 +191,11 @@ pub mod ffi {
fn refreshAsync(self: Pin<&mut Wallet>) -> Result<()>; fn refreshAsync(self: Pin<&mut Wallet>) -> Result<()>;
/// Set the daemon address. /// Set the daemon address.
fn setWalletDaemon(wallet: Pin<&mut Wallet>, daemon_address: &CxxString) -> Result<bool>; fn setWalletDaemon(
wallet: Pin<&mut Wallet>,
daemon_address: &CxxString,
try_ssl: bool,
) -> Result<bool>;
/// Set whether the daemon is trusted. /// Set whether the daemon is trusted.
fn setTrustedDaemon(self: Pin<&mut Wallet>, trusted: bool) -> Result<()>; fn setTrustedDaemon(self: Pin<&mut Wallet>, trusted: bool) -> Result<()>;

View file

@ -1394,7 +1394,7 @@ impl FfiWallet {
.map_err(|e| anyhow!("Failed to initialize wallet: {e}"))?; .map_err(|e| anyhow!("Failed to initialize wallet: {e}"))?;
tracing::debug!("Initialized wallet, setting daemon address"); tracing::debug!("Initialized wallet, setting daemon address");
wallet.set_daemon_address(&daemon.address)?; wallet.set_daemon(&daemon)?;
if background_sync { if background_sync {
tracing::debug!("Background sync enabled, starting refresh thread"); 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") 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<()> { pub fn set_daemon(&mut self, daemon: &Daemon) -> anyhow::Result<()> {
tracing::debug!(%address, "Setting daemon address"); let ssl = daemon.ssl;
let address = daemon.address.clone();
tracing::debug!(%address, %ssl, "Setting daemon address");
let_cxx_string!(address = address); let_cxx_string!(address = address);
let raw_wallet = &mut self.inner; 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")?; .context("Failed to set daemon address: FFI call failed with exception")?;
if !success { if !success {

View file

@ -44,6 +44,7 @@ import {
SetRestoreHeightArgs, SetRestoreHeightArgs,
SetRestoreHeightResponse, SetRestoreHeightResponse,
GetRestoreHeightResponse, GetRestoreHeightResponse,
MoneroNodeConfig,
} from "models/tauriModel"; } from "models/tauriModel";
import { import {
rpcSetBalance, rpcSetBalance,
@ -641,3 +642,33 @@ export async function saveFilesInDialog(files: Record<string, string>) {
export async function dfxAuthenticate(): Promise<DfxAuthenticateResponse> { export async function dfxAuthenticate(): Promise<DfxAuthenticateResponse> {
return await invokeNoArgs<DfxAuthenticateResponse>("dfx_authenticate"); return await invokeNoArgs<DfxAuthenticateResponse>("dfx_authenticate");
} }
export async function changeMoneroNode(
nodeConfig: MoneroNodeConfig,
): Promise<void> {
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<MoneroNodeConfig> {
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;
}

View file

@ -7,6 +7,8 @@ import {
fetchSellersAtPresetRendezvousPoints, fetchSellersAtPresetRendezvousPoints,
getSwapInfo, getSwapInfo,
initializeMoneroWallet, initializeMoneroWallet,
changeMoneroNode,
getCurrentMoneroNodeConfig,
} from "renderer/rpc"; } from "renderer/rpc";
import logger from "utils/logger"; import logger from "utils/logger";
import { contextStatusEventReceived } from "store/features/rpcSlice"; import { contextStatusEventReceived } from "store/features/rpcSlice";
@ -14,6 +16,9 @@ import {
addNode, addNode,
setFetchFiatPrices, setFetchFiatPrices,
setFiatCurrency, setFiatCurrency,
setUseMoneroRpcPool,
Blockchain,
Network,
} from "store/features/settingsSlice"; } from "store/features/settingsSlice";
import { fetchFeedbackMessagesViaHttp, updateRates } from "renderer/api"; import { fetchFeedbackMessagesViaHttp, updateRates } from "renderer/api";
import { store } from "renderer/store/storeRenderer"; import { store } from "renderer/store/storeRenderer";
@ -22,7 +27,8 @@ import {
addFeedbackId, addFeedbackId,
setConversation, setConversation,
} from "store/features/conversationsSlice"; } 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 // Create a Map to store throttled functions per swap_id
const throttledGetSwapInfoFunctions = new Map< 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 for when a feedback id is added
listener.startListening({ listener.startListening({
actionCreator: addFeedbackId, actionCreator: addFeedbackId,

View file

@ -6,11 +6,11 @@ use swap::cli::{
api::{ api::{
data, data,
request::{ request::{
BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, CheckElectrumNodeArgs, BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, ChangeMoneroNodeArgs,
CheckElectrumNodeResponse, CheckMoneroNodeArgs, CheckMoneroNodeResponse, CheckSeedArgs, CheckElectrumNodeArgs, CheckElectrumNodeResponse, CheckMoneroNodeArgs,
CheckSeedResponse, DfxAuthenticateResponse, ExportBitcoinWalletArgs, CheckMoneroNodeResponse, CheckSeedArgs, CheckSeedResponse, DfxAuthenticateResponse,
GetCurrentSwapArgs, GetDataDirArgs, GetHistoryArgs, GetLogsArgs, ExportBitcoinWalletArgs, GetCurrentSwapArgs, GetDataDirArgs, GetHistoryArgs,
GetMoneroAddressesArgs, GetMoneroBalanceArgs, GetMoneroHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, GetMoneroBalanceArgs, GetMoneroHistoryArgs,
GetMoneroMainAddressArgs, GetMoneroSyncProgressArgs, GetPendingApprovalsResponse, GetMoneroMainAddressArgs, GetMoneroSyncProgressArgs, GetPendingApprovalsResponse,
GetRestoreHeightArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs, GetRestoreHeightArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs,
MoneroRecoveryArgs, RedactArgs, RejectApprovalArgs, RejectApprovalResponse, MoneroRecoveryArgs, RedactArgs, RejectApprovalArgs, RejectApprovalResponse,
@ -212,6 +212,7 @@ pub fn run() {
reject_approval_request, reject_approval_request,
get_restore_height, get_restore_height,
dfx_authenticate, dfx_authenticate,
change_monero_node,
]) ])
.setup(setup) .setup(setup)
.build(tauri::generate_context!()) .build(tauri::generate_context!())
@ -253,6 +254,7 @@ tauri_command!(list_sellers, ListSellersArgs);
tauri_command!(cancel_and_refund, CancelAndRefundArgs); tauri_command!(cancel_and_refund, CancelAndRefundArgs);
tauri_command!(redact, RedactArgs); tauri_command!(redact, RedactArgs);
tauri_command!(send_monero, SendMoneroArgs); tauri_command!(send_monero, SendMoneroArgs);
tauri_command!(change_monero_node, ChangeMoneroNodeArgs);
// These commands require no arguments // These commands require no arguments
tauri_command!(get_wallet_descriptor, ExportBitcoinWalletArgs, no_args); tauri_command!(get_wallet_descriptor, ExportBitcoinWalletArgs, no_args);

View file

@ -644,6 +644,65 @@ impl Context {
pub fn tauri_handle(&self) -> Option<TauriHandle> { pub fn tauri_handle(&self) -> Option<TauriHandle> {
self.tauri_handle.clone() 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 { impl fmt::Debug for Context {

View file

@ -1,7 +1,7 @@
use super::tauri_bindings::TauriHandle; use super::tauri_bindings::TauriHandle;
use crate::bitcoin::{wallet, CancelTimelock, ExpiredTimelocks, PunishTimelock}; use crate::bitcoin::{wallet, CancelTimelock, ExpiredTimelocks, PunishTimelock};
use crate::cli::api::tauri_bindings::{ use crate::cli::api::tauri_bindings::{
ApprovalRequestType, SelectMakerDetails, SendMoneroDetails, TauriEmitter, ApprovalRequestType, MoneroNodeConfig, SelectMakerDetails, SendMoneroDetails, TauriEmitter,
TauriSwapProgressEvent, TauriSwapProgressEvent,
}; };
use crate::cli::api::Context; use crate::cli::api::Context;
@ -2006,3 +2006,37 @@ pub struct DfxAuthenticateResponse {
pub access_token: String, pub access_token: String,
pub kyc_url: 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<Context>) -> Result<Self::Response> {
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<Context>,
) -> Result<ChangeMoneroNodeResponse> {
context
.change_monero_node(args.node_config)
.await
.context("Failed to change Monero node")?;
Ok(ChangeMoneroNodeResponse { success: true })
}

View file

@ -12,6 +12,7 @@ use anyhow::{Context, Result};
use monero::{Address, Network}; use monero::{Address, Network};
use monero_sys::WalletEventListener; use monero_sys::WalletEventListener;
pub use monero_sys::{Daemon, WalletHandle as Wallet, WalletHandleListener}; pub use monero_sys::{Daemon, WalletHandle as Wallet, WalletHandleListener};
use tokio::sync::RwLock;
use uuid::Uuid; use uuid::Uuid;
use crate::cli::api::{ use crate::cli::api::{
@ -29,7 +30,7 @@ pub struct Wallets {
/// The network we're on. /// The network we're on.
network: Network, network: Network,
/// The monero node we connect to. /// The monero node we connect to.
daemon: Daemon, daemon: Arc<RwLock<Daemon>>,
/// Keep the main wallet open and synced. /// Keep the main wallet open and synced.
main_wallet: Arc<Wallet>, main_wallet: Arc<Wallet>,
/// Since Network::Regtest isn't a thing we have to use an extra flag. /// Since Network::Regtest isn't a thing we have to use an extra flag.
@ -275,6 +276,8 @@ impl Wallets {
.await; .await;
} }
let daemon = Arc::new(RwLock::new(daemon));
let wallets = Self { let wallets = Self {
wallet_dir, wallet_dir,
network, network,
@ -323,6 +326,8 @@ impl Wallets {
.await; .await;
} }
let daemon = Arc::new(RwLock::new(daemon));
let wallets = Self { let wallets = Self {
wallet_dir, wallet_dir,
network, network,
@ -367,6 +372,8 @@ impl Wallets {
.await .await
.context("Couldn't fetch blockchain height")?; .context("Couldn't fetch blockchain height")?;
let daemon = self.daemon.read().await.clone();
let wallet = Wallet::open_or_create_from_keys( let wallet = Wallet::open_or_create_from_keys(
wallet_path.clone(), wallet_path.clone(),
None, None,
@ -376,7 +383,7 @@ impl Wallets {
spend_key, spend_key,
blockheight, blockheight,
false, // We don't sync the swap wallet, just import the transaction false, // We don't sync the swap wallet, just import the transaction
self.daemon.clone(), daemon,
) )
.await .await
.context(format!( .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 /// Get the last 5 recently used wallets
pub async fn get_recent_wallets(&self) -> Result<Vec<String>> { pub async fn get_recent_wallets(&self) -> Result<Vec<String>> {
if let Some(db) = &self.wallet_database { if let Some(db) = &self.wallet_database {