fix(gui): "Approval not found or already handled"

This commit is contained in:
Binarybaron 2025-07-04 13:40:54 +02:00
parent 3ebaaad1fa
commit 293ff2cdf3
11 changed files with 167 additions and 64 deletions

4
Cargo.lock generated
View file

@ -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",

View file

@ -23,7 +23,8 @@
"monero-sys/monero/",
".git/**",
"**/node_modules/**",
"**/dist/**"
"**/dist/**",
"seed/**"
],
"plugins": [
"https://plugins.dprint.dev/markdown-0.18.0.wasm",

View file

@ -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<void> {
listSellersAtRendezvousPoint(store.getState().settings.rendezvousPoints),
DISCOVER_PEERS_INTERVAL,
);
setIntervalImmediate(refreshApprovals, FETCH_PENDING_APPROVALS_INTERVAL);
// Fetch all alerts
updateAlerts();

View file

@ -19,10 +19,17 @@ export default function SwapWidget() {
sx={{ display: "flex", flexDirection: "column", gap: 2, width: "100%" }}
>
<SwapStatusAlert swap={swapInfo} onlyShowIfUnusualAmountOfTimeHasPassed />
<Dialog fullWidth maxWidth="md" open={debug} onClose={() => setDebug(false)}>
<Dialog
fullWidth
maxWidth="md"
open={debug}
onClose={() => setDebug(false)}
>
<DebugPage />
<DialogActions>
<Button variant="outlined" onClick={() => setDebug(false)}>Close</Button>
<Button variant="outlined" onClick={() => setDebug(false)}>
Close
</Button>
</DialogActions>
</Dialog>
<Paper

View file

@ -31,8 +31,14 @@ import {
RedactResponse,
GetCurrentSwapResponse,
LabeledMoneroAddress,
GetPendingApprovalsArgs,
GetPendingApprovalsResponse,
} from "models/tauriModel";
import { rpcSetBalance, rpcSetSwapInfo } from "store/features/rpcSlice";
import {
rpcSetBalance,
rpcSetSwapInfo,
approvalRequestsReplaced,
} from "store/features/rpcSlice";
import { store } from "./store/storeRenderer";
import { Maker } from "models/apiModel";
import { providerToConcatenatedMultiAddr } from "utils/multiAddrUtils";
@ -422,10 +428,23 @@ export async function resolveApproval(
requestId: string,
accept: object,
): Promise<void> {
await invoke<ResolveApprovalArgs, ResolveApprovalResponse>(
"resolve_approval_request",
{ request_id: requestId, accept },
try {
await invoke<ResolveApprovalArgs, ResolveApprovalResponse>(
"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<void> {
const response = await invokeNoArgs<GetPendingApprovalsResponse>(
"get_pending_approvals",
);
store.dispatch(approvalRequestsReplaced(response.approvals));
}
export async function checkSeed(seed: string): Promise<boolean> {

View file

@ -140,6 +140,13 @@ export const rpcSlice = createSlice({
const requestId = event.request_id;
slice.state.approvalRequests[requestId] = event;
},
approvalRequestsReplaced(slice, action: PayloadAction<ApprovalRequest[]>) {
// 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<TauriBackgroundProgressWrapper>,
@ -165,6 +172,7 @@ export const {
rpcSetBackgroundRefundState,
timelockChangeEventReceived,
approvalEventReceived,
approvalRequestsReplaced,
backgroundProgressEventReceived,
backgroundProgressEventRemoved,
} = rpcSlice.actions;

View file

@ -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<State>>,
) -> Result<GetPendingApprovalsResponse, String> {
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<State>>,
) -> Result<(), String> {
// When the app crashes, the monero-wallet-rpc process may not be killed

View file

@ -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<crate::cli::api::tauri_bindings::ApprovalRequest>,
}

View file

@ -113,6 +113,7 @@ struct PendingApproval {
responder: Option<oneshot::Sender<serde_json::Value>>,
#[allow(dead_code)]
expiration_ts: u64,
request: ApprovalRequest,
}
impl Drop for PendingApproval {
@ -182,7 +183,7 @@ impl TauriHandle {
timeout_secs: Option<u64>,
) -> Result<Response>
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<Response> = serde_json::from_value(unparsed_response.clone())
.context("Failed to parse approval response to expected type");
let maybe_response: Option<Response> = 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<Vec<ApprovalRequest>> {
#[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<ApprovalRequest> = 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,
});
}
}
}
});
}
}
}

View file

@ -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,

View file

@ -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(())
}
}