diff --git a/Cargo.lock b/Cargo.lock index ccce6d56..42eb4638 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9769,7 +9769,7 @@ checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" [[package]] name = "swap" -version = "2.3.3" +version = "2.4.0" dependencies = [ "anyhow", "arti-client", @@ -12323,7 +12323,7 @@ dependencies = [ [[package]] name = "unstoppableswap-gui-rs" -version = "2.3.3" +version = "2.4.0" dependencies = [ "anyhow", "monero-rpc-pool", diff --git a/dprint.json b/dprint.json index a08f3ca6..ba092218 100644 --- a/dprint.json +++ b/dprint.json @@ -23,7 +23,8 @@ "monero-sys/monero/", ".git/**", "**/node_modules/**", - "**/dist/**" + "**/dist/**", + "seed/**" ], "plugins": [ "https://plugins.dprint.dev/markdown-0.18.0.wasm", diff --git a/src-gui/src/renderer/background.ts b/src-gui/src/renderer/background.ts index 7f633a76..e74a1262 100644 --- a/src-gui/src/renderer/background.ts +++ b/src-gui/src/renderer/background.ts @@ -22,6 +22,7 @@ import { getSwapInfo, initializeContext, listSellersAtRendezvousPoint, + refreshApprovals, updateAllNodeStatuses, } from "./rpc"; import { store } from "./store/storeRenderer"; @@ -44,6 +45,9 @@ const UPDATE_RATE_INTERVAL = 5 * 60 * 1_000; // Fetch all conversations every 10 minutes const FETCH_CONVERSATIONS_INTERVAL = 10 * 60 * 1_000; +// Fetch pending approvals every 10 seconds +const FETCH_PENDING_APPROVALS_INTERVAL = 2 * 1_000; + function setIntervalImmediate(callback: () => void, interval: number): void { callback(); setInterval(callback, interval); @@ -60,6 +64,7 @@ export async function setupBackgroundTasks(): Promise { listSellersAtRendezvousPoint(store.getState().settings.rendezvousPoints), DISCOVER_PEERS_INTERVAL, ); + setIntervalImmediate(refreshApprovals, FETCH_PENDING_APPROVALS_INTERVAL); // Fetch all alerts updateAlerts(); diff --git a/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx b/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx index 9fd8b73d..e223b4ef 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx @@ -19,10 +19,17 @@ export default function SwapWidget() { sx={{ display: "flex", flexDirection: "column", gap: 2, width: "100%" }} > - setDebug(false)}> + setDebug(false)} + > - + { - await invoke( - "resolve_approval_request", - { request_id: requestId, accept }, + try { + await invoke( + "resolve_approval_request", + { request_id: requestId, accept }, + ); + } catch (error) { + // Refresh approval list when resolve fails to keep UI in sync + await refreshApprovals(); + throw error; + } +} + +export async function refreshApprovals(): Promise { + const response = await invokeNoArgs( + "get_pending_approvals", ); + store.dispatch(approvalRequestsReplaced(response.approvals)); } export async function checkSeed(seed: string): Promise { diff --git a/src-gui/src/store/features/rpcSlice.ts b/src-gui/src/store/features/rpcSlice.ts index 7c7a8bff..36138b30 100644 --- a/src-gui/src/store/features/rpcSlice.ts +++ b/src-gui/src/store/features/rpcSlice.ts @@ -140,6 +140,13 @@ export const rpcSlice = createSlice({ const requestId = event.request_id; slice.state.approvalRequests[requestId] = event; }, + approvalRequestsReplaced(slice, action: PayloadAction) { + // Clear existing approval requests and replace with new ones + slice.state.approvalRequests = {}; + action.payload.forEach((approval) => { + slice.state.approvalRequests[approval.request_id] = approval; + }); + }, backgroundProgressEventReceived( slice, action: PayloadAction, @@ -165,6 +172,7 @@ export const { rpcSetBackgroundRefundState, timelockChangeEventReceived, approvalEventReceived, + approvalRequestsReplaced, backgroundProgressEventReceived, backgroundProgressEventRemoved, } = rpcSlice.actions; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c48bd411..b44c0e64 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,8 +10,8 @@ use swap::cli::{ BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, CheckElectrumNodeArgs, CheckElectrumNodeResponse, CheckMoneroNodeArgs, CheckMoneroNodeResponse, CheckSeedArgs, CheckSeedResponse, ExportBitcoinWalletArgs, GetCurrentSwapArgs, GetDataDirArgs, - GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, GetSwapInfoArgs, - GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs, + GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, GetPendingApprovalsResponse, + GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs, ResolveApprovalArgs, ResumeSwapArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs, }, tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings}, @@ -201,6 +201,7 @@ pub fn run() { redact, save_txt_files, check_seed, + get_pending_approvals, ]) .setup(setup) .build(tauri::generate_context!()) @@ -354,17 +355,30 @@ async fn resolve_approval_request( .resolve_approval(args.request_id.parse().unwrap(), args.accept) .await .to_string_result()?; - println!("Resolved approval request"); Ok(()) } +#[tauri::command] +async fn get_pending_approvals( + state: tauri::State<'_, RwLock>, +) -> Result { + let approvals = state + .read() + .await + .handle + .get_pending_approvals() + .await + .to_string_result()?; + + Ok(GetPendingApprovalsResponse { approvals }) +} + /// Tauri command to initialize the Context #[tauri::command] async fn initialize_context( settings: TauriSettings, testnet: bool, - app_handle: tauri::AppHandle, state: tauri::State<'_, RwLock>, ) -> Result<(), String> { // When the app crashes, the monero-wallet-rpc process may not be killed diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index ff9cd166..d424e115 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -8,7 +8,7 @@ use crate::common::{get_logs, redact}; use crate::libp2p_ext::MultiAddrExt; use crate::monero::wallet_rpc::MoneroDaemon; use crate::monero::MoneroAddressPool; -use crate::network::quote::{BidQuote, ZeroQuoteReceived}; +use crate::network::quote::BidQuote; use crate::network::rendezvous::XmrBtcNamespace; use crate::network::swarm; use crate::protocol::bob::{BobState, Swap}; @@ -1658,3 +1658,9 @@ impl CheckSeedArgs { }) } } + +#[typeshare] +#[derive(Serialize, Deserialize, Debug)] +pub struct GetPendingApprovalsResponse { + pub approvals: Vec, +} diff --git a/swap/src/cli/api/tauri_bindings.rs b/swap/src/cli/api/tauri_bindings.rs index a489a711..c1103f48 100644 --- a/swap/src/cli/api/tauri_bindings.rs +++ b/swap/src/cli/api/tauri_bindings.rs @@ -113,6 +113,7 @@ struct PendingApproval { responder: Option>, #[allow(dead_code)] expiration_ts: u64, + request: ApprovalRequest, } impl Drop for PendingApproval { @@ -182,7 +183,7 @@ impl TauriHandle { timeout_secs: Option, ) -> Result where - Response: serde::de::DeserializeOwned + Clone, + Response: serde::de::DeserializeOwned + Clone + Serialize, { #[cfg(not(feature = "tauri"))] { @@ -191,31 +192,28 @@ impl TauriHandle { #[cfg(feature = "tauri")] { - // Emit the creation of the approval request to the frontend - // TODO: We need to send a UUID with it here - + // Create the approval request + // Generate the UUID + // Set the expiration timestamp + let (responder, receiver) = oneshot::channel(); let request_id = Uuid::new_v4(); - // No timeout = one week let timeout_secs = timeout_secs.unwrap_or(60 * 60 * 24 * 7); + let timeout_duration = Duration::from_secs(timeout_secs); let expiration_ts = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|e| anyhow!("Failed to get current time: {}", e))? .as_secs() - + timeout_secs; + + timeout_duration.as_secs(); let request = ApprovalRequest { request: request_type, request_status: RequestStatus::Pending { expiration_ts }, request_id, }; - use anyhow::bail; + // Emit the "pending" event self.emit_approval(request.clone()); tracing::debug!(%request, "Emitted approval request event"); - // Construct the data structure we use to internally track the approval request - let (responder, receiver) = oneshot::channel(); - - let timeout_duration = Duration::from_secs(timeout_secs); let pending = PendingApproval { responder: Some(responder), @@ -224,6 +222,7 @@ impl TauriHandle { .map_err(|e| anyhow!("Failed to get current time: {}", e))? .as_secs() + timeout_secs, + request: request.clone(), }; // Lock map and insert the pending approval @@ -246,41 +245,48 @@ impl TauriHandle { // Determine if the request will be accepted or rejected // Either by being resolved by the user, or by timing out let unparsed_response = tokio::select! { - res = receiver => res.map_err(|_| anyhow!("Approval responder dropped"))?, + res = receiver => Some(res.map_err(|_| anyhow!("Approval responder dropped"))?), _ = tokio::time::sleep(timeout_duration) => { - bail!("Approval request timed out and was therefore rejected"); + None }, }; - tracing::debug!(%unparsed_response, "Received approval response"); - - let response: Result = serde_json::from_value(unparsed_response.clone()) - .context("Failed to parse approval response to expected type"); + let maybe_response: Option = match &unparsed_response { + Some(value) => serde_json::from_value(value.clone()) + .inspect_err(|e| { + tracing::error!("Failed to parse approval response to expected type: {}", e) + }) + .ok(), + None => None, + }; let mut map = self .0 .pending_approvals .lock() .map_err(|e| anyhow!("Failed to acquire approval lock: {}", e))?; + if let Some(_pending) = map.remove(&request_id) { - let status = if response.is_ok() { - RequestStatus::Resolved { - approve_input: unparsed_response, - } - } else { - RequestStatus::Rejected + let status = match &maybe_response { + Some(_) => RequestStatus::Resolved { + approve_input: unparsed_response.unwrap_or(serde_json::Value::Bool(false)), + }, + None => RequestStatus::Rejected, }; + // Set the status and emit the event let mut approval = request.clone(); - approval.request_status = status.clone(); + approval.request_status = status; + self.emit_approval(approval.clone()); tracing::debug!(%approval, "Resolved approval request"); - self.emit_approval(approval); } + cleanup_guard.disarm(); + tracing::debug!("Returning approval response"); - response + maybe_response.context("Approval was rejected") } } @@ -316,6 +322,29 @@ impl TauriHandle { } } } + + pub async fn get_pending_approvals(&self) -> Result> { + #[cfg(not(feature = "tauri"))] + { + return Ok(Vec::new()); + } + + #[cfg(feature = "tauri")] + { + let pending_map = self + .0 + .pending_approvals + .lock() + .map_err(|e| anyhow!("Failed to acquire approval lock: {}", e))?; + + let approvals: Vec = pending_map + .values() + .map(|pending| pending.request.clone()) + .collect(); + + Ok(approvals) + } + } } impl Display for ApprovalRequest { @@ -892,19 +921,30 @@ impl ApprovalCleanupGuard { impl Drop for ApprovalCleanupGuard { fn drop(&mut self) { - if let Some(request_id) = self.request_id { - tracing::debug!(%request_id, "Approval handle dropped, we should cleanup now"); + if let Some(request_id) = self.request_id.take() { + let approval_store = self.approval_store.clone(); + let handle = self.handle.clone(); - // Lock the Mutex - if let Ok(mut approval_store) = self.approval_store.lock() { - // Check if the request id still present in the map - if let Some(mut pending_approval) = approval_store.remove(&request_id) { - // If there is still someone listening, send a rejection - if let Some(responder) = pending_approval.responder.take() { - let _ = responder.send(serde_json::Value::Bool(false)); + tokio::task::spawn_blocking(move || { + tracing::debug!(%request_id, "Approval handle dropped, we should cleanup now"); + + // Lock the Mutex + if let Ok(mut approval_store) = approval_store.lock() { + // Check if the request id still present in the map + if let Some(mut pending_approval) = approval_store.remove(&request_id) { + // If there is still someone listening, send a rejection + if let Some(responder) = pending_approval.responder.take() { + let _ = responder.send(serde_json::Value::Bool(false)); + } + + handle.emit_approval(ApprovalRequest { + request: pending_approval.request.clone().request, + request_status: RequestStatus::Rejected, + request_id, + }); } } - } + }); } } } diff --git a/swap/src/cli/list_sellers.rs b/swap/src/cli/list_sellers.rs index 8bc539f9..489b915a 100644 --- a/swap/src/cli/list_sellers.rs +++ b/swap/src/cli/list_sellers.rs @@ -99,7 +99,7 @@ pub async fn list_sellers_init( Some(db) => match db.get_all_peer_addresses().await { Ok(peers) => VecDeque::from(peers), Err(err) => { - tracing::error!(%err, "Failed to get peers from database for list_sellers"); + tracing::trace!(%err, "Failed to get peers from database for list_sellers"); VecDeque::new() } }, @@ -714,7 +714,7 @@ impl EventLoop { .insert(*peer_id, RendezvousPointStatus::Dialed); if let Err(e) = self.swarm.dial(dial_opts) { - tracing::error!(%peer_id, %multiaddr, error = %e, "Failed to dial rendezvous point"); + tracing::trace!(%peer_id, %multiaddr, error = %e, "Failed to dial rendezvous point"); self.rendezvous_points_status .insert(*peer_id, RendezvousPointStatus::Failed); @@ -754,7 +754,7 @@ impl EventLoop { match swarm_event { SwarmEvent::ConnectionEstablished { peer_id, endpoint, .. } => { if self.is_rendezvous_point(&peer_id) { - tracing::info!( + tracing::trace!( "Connected to rendezvous point, discovering nodes in '{}' namespace ...", self.namespace ); @@ -769,7 +769,7 @@ impl EventLoop { ); } else { let address = endpoint.get_remote_address(); - tracing::debug!(%peer_id, %address, "Connection established to peer for list-sellers"); + tracing::trace!(%peer_id, %address, "Connection established to peer for list-sellers"); self.reachable_asb_address.insert(peer_id, address.clone()); // Update the peer state with the reachable address @@ -782,7 +782,7 @@ impl EventLoop { SwarmEvent::OutgoingConnectionError { peer_id, error, .. } => { if let Some(peer_id) = peer_id { if let Some(rendezvous_point) = self.get_rendezvous_point(&peer_id) { - tracing::warn!( + tracing::trace!( %peer_id, %rendezvous_point, "Failed to connect to rendezvous point: {}", @@ -792,7 +792,7 @@ impl EventLoop { // Update the status of the rendezvous point to failed self.rendezvous_points_status.insert(peer_id, RendezvousPointStatus::Failed); } else { - tracing::warn!( + tracing::trace!( %peer_id, "Failed to connect to peer: {}", error @@ -804,13 +804,13 @@ impl EventLoop { } } } else { - tracing::warn!("Failed to connect (no peer id): {}", error); + tracing::trace!("Failed to connect (no peer id): {}", error); } } SwarmEvent::Behaviour(OutEvent::Rendezvous( libp2p::rendezvous::client::Event::Discovered { registrations, rendezvous_node, .. }, )) => { - tracing::debug!(%rendezvous_node, num_peers = %registrations.len(), "Discovered peers at rendezvous point"); + tracing::trace!(%rendezvous_node, num_peers = %registrations.len(), "Discovered peers at rendezvous point"); for registration in registrations { let peer = registration.record.peer_id(); @@ -836,7 +836,7 @@ impl EventLoop { let new_state = state.apply_quote(Ok(response)); self.peer_states.insert(peer, new_state); } else { - tracing::warn!(%peer, "Received bid quote from unexpected peer, this record will be removed!"); + tracing::trace!(%peer, "Received bid quote from unexpected peer, this record will be removed!"); } } request_response::Message::Request { .. } => unreachable!("we only request quotes, not respond") @@ -844,7 +844,7 @@ impl EventLoop { } request_response::Event::OutboundFailure { peer, error, .. } => { if self.is_rendezvous_point(&peer) { - tracing::debug!(%peer, "Outbound failure when communicating with rendezvous node: {:#}", error); + tracing::trace!(%peer, "Outbound failure when communicating with rendezvous node: {:#}", error); // Update the status of the rendezvous point to failed self.rendezvous_points_status.insert(peer, RendezvousPointStatus::Failed); @@ -855,7 +855,7 @@ impl EventLoop { } request_response::Event::InboundFailure { peer, error, .. } => { if self.is_rendezvous_point(&peer) { - tracing::debug!(%peer, "Inbound failure when communicating with rendezvous node: {:#}", error); + tracing::trace!(%peer, "Inbound failure when communicating with rendezvous node: {:#}", error); // Update the status of the rendezvous point to failed self.rendezvous_points_status.insert(peer, RendezvousPointStatus::Failed); @@ -876,7 +876,7 @@ impl EventLoop { } } identify::Event::Error { peer_id, error } => { - tracing::error!(%peer_id, error = %error, "Error when identifying peer"); + tracing::trace!(%peer_id, error = %error, "Error when identifying peer"); if let Some(state) = self.peer_states.remove(&peer_id) { let failed_state = state.mark_failed(format!("Error when identifying peer: {}", error)); @@ -937,7 +937,7 @@ impl EventLoop { error_message, .. } => { - tracing::warn!(%peer_id, error = %error_message, "Peer failed"); + tracing::trace!(%peer_id, error = %error_message, "Peer failed"); Ok(SellerStatus::Unreachable(UnreachableSeller { peer_id: *peer_id, diff --git a/swap/src/database/sqlite.rs b/swap/src/database/sqlite.rs index ba97588b..1028695b 100644 --- a/swap/src/database/sqlite.rs +++ b/swap/src/database/sqlite.rs @@ -55,7 +55,10 @@ impl SqliteDatabase { } async fn run_migrations(&mut self) -> anyhow::Result<()> { - sqlx::migrate!("./migrations").run(&self.pool).await?; + sqlx::migrate!("./migrations") + .set_ignore_missing(true) + .run(&self.pool) + .await?; Ok(()) } }