feat(gui): Monero wallet (#442)

* feat(gui): Monero wallet

* progress

* refactor

* progress, dont delete wallet, re-fetch approvals and background periodically

* show transaction history correctly

* Enable fetching tx hashes

* Try add the wallet listener event callbacks, not working

* fix: Redeem XMR to internal main wallet, not temp wallet

* type safety

* refactoring of callback system

* make free floating functions generic

* refactor: Format files

* refactor(gui): Split wallet components and redesign balanceOverview component

* refactor(gui): Add action buttons and transaction section

* wrapper event listener

* progress, compiles

* works!

* WORKS! Event received on balance change

* refactor: format and slight refactorings and comments

* refactor(gui): Start with implementation of send dialog

- new number input
- new button variant and size

* add @tauri-apps/plugin-dialog

* feat(gui): Add permissions for file dialog

* fix(monero-harness): Compile issue

* feat(gui): Extract seed from Monero wallet and use for derivation, allow opening existing wallet file

* feat(gui): Always refresh the approval list from frontend when resolving

* fix(monero-rpc-pool): Implement Into<String> for ServerInfo

* fix(monero-sys): Use oneshot channel for all wallets

* feat(gui, monero-sys): Display recently opened wallets

* small refactors

* fix(gui): Enable background_sync, display temp "Loading..." if values are null

* feat(gui): Remove headers from pages, show selected navigation item

* feat(gui): Explicitly tell user if no swaps have been made yet

* feat(gui): send sync and history updates

* feat(gui): Fetch monero wallet details when context becomes availiable

* feat(gui): Display Monero primary address without modal

* feat(gui): Make "swap" button on wallet page take you to "/swap"

* feat(gui): Rework send modal, adjust number input, added send to field

* feat(gui): set block restore height, not working

* refactor(gui): Optimize number input and add support for switching between currency

* feat(gui): Display real fiat currency prices in send modal

* feat(gui): Add error message for too high send amount

* feat(gui): Modern UI for SeedSelectionDialog

* feat(gui): Wrap MoneroWalletActions

* wip

* refactoring approval callback

* feat(gui): Send Direction of Transaction in History to Frontend

* feat(gui): Let user approve transaction before publishing

* feat: Display 8 digits for Monero amounts by default

* feat(monero-sys): Store pending (non published) transactions in Mutex map inside wallet thread

This allows seperating signing and publishing transactions cleanly

* dprint fmt

* fix(gui): Refresh Monero wallet history C++ struct before serializing

* feat(monero-rpc-pool): Fail after three JSON-RPC errors

* feat(monero-sys): Add wrapper around verify_wallet_password

* feat(gui): Allow opening password-protected Wallets

* refactor: fmt, remove receive button

* fix(gui): Convert to XMR before converting into Fiat

* feat(gui): Add dialog for setting restore height

* feat(gui): block height can be changed, blocks when too low

* refactor(monero-sys): Remove old WalletListener code

* feat(gui): Continually ask for user to select wallet and enter password, if user rejects, offer to select different wallet

* refactor(swap): Extract "select Monero wallet" into own function

* refactor(tauri): Dont kill monero-wallet-rpc

* refactor(tauri): Avoid multiple concurrent Contexts starting

* refactor: Change "Cancel" to "Change wallet" on PasswordEntryDialog

* feat(gui): show curent block height, fix blockage

* Cargo.lock update

* refactor(monero-sys): Use match instead of is_err() and expect(...)

* refactor: better context for WalletHandle constructor method errors handling

* refactor(monero-sys): Common open_with<F>(path: String, daemon: Daemon, wallet_op: F) function

* feat: check empty password before requeston password for wallet

* feat: Remove "Checking for available remote nodes" from frontend

* feat(gui): Allow sweeping entire Monero balance

* feat(monero-rpc-pool): Keep alive TCP connections, do not record JSON-RPC errors as failure if >=3 nodes failed

If >=3 nodes failed we assume it was an actual issue on our side, not an issue with the node

* refactor(swap): Remove dead code

* add comment to WalletHandleListener::on_refreshed{...}

* feat(gui): show current block height in the field

* refactor: remove unused UserCancelledError;

* refactor: No Arc<Mutex<_>> for Pending TXs map

* refactor: remove redundant } catch (error) {

* feat: add our new crates to `OUR_CRATES` in tracing util

* fix(gui): Add math.ceil to piconero conversion to ensure integer

* fix(gui): Close menu when option is clicked

* review and improve/reduce uses of unsafe, also remove unique_ptr wrapper around TransactionHistory to avoid double free

* fix(gui): Use monero amount from units.tsx

* fix(gui): Use PromiseInvokeButton for simplification for approving of send transaction

* update comment, rename function

* refactor(gui): Fix alignment of amounts

* refactor(gui): Remove sending and refreshing states from wallet

* fix(cli, gui): use old seed flow on no tauri, fix minor issues in gui

* fix: use the new named function

* refactor(gui): Add skeletons for monero wallet when still loading

* refactor(gui): Remove isLoading from wallet slice

* feat(gui): Add success dialog after send transaction was approved

* fix(gui): Floor piconero amount in sendMoneroTransaction

* feat(gui): Allow view on explorer button on send success modal

* feat(backend): save the wallet state on events

* fix(structure): move throttle into its own crate

* fix(log): remove spammy logs

* fix(logs): log folder in confid

* remove "sync progress: " log

* small refactors

* save wallet at most every 60s

* remove useless logs

* underscore unused variables

* feat(gui): Add timestamp of the tx

* feat(gui): Add the legacy wallet init option

* legac ybutton

* Fix(gui, asb): reverse the log config
remove log in bridge.h
cleanup

* use none for .store(..)

* display dot for running swap

---------

Co-authored-by: Maksim Kirillov <maksim.kirillov@staticlabs.de>
Co-authored-by: b-enedict <benedict.seuss@gmail.com>
Co-authored-by: einliterflasche <einliterflasche@pm.me>
This commit is contained in:
Mohan 2025-07-18 15:08:36 +02:00 committed by GitHub
parent eb0dc10489
commit a7823d7489
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
118 changed files with 7857 additions and 3456 deletions

View file

@ -21,7 +21,6 @@ rustls = { version = "0.23.26", default-features = false, features = ["ring"] }
serde = { workspace = true }
serde_json = { workspace = true }
swap = { path = "../swap", features = [ "tauri" ] }
sysinfo = "=0.32.1"
tauri = { version = "^2.0.0", features = [ "config-json5" ] }
tauri-plugin-clipboard-manager = "^2.0.0"
tauri-plugin-dialog = "2.2.2"

View file

@ -13,6 +13,8 @@
"cli:allow-cli-matches",
"updater:default",
"process:allow-restart",
"opener:default"
"opener:default",
"dialog:allow-open",
"dialog:default"
]
}

View file

@ -1,4 +1,3 @@
use anyhow::Context as AnyhowContext;
use std::collections::HashMap;
use std::io::Write;
use std::result::Result;
@ -10,9 +9,12 @@ use swap::cli::{
BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, CheckElectrumNodeArgs,
CheckElectrumNodeResponse, CheckMoneroNodeArgs, CheckMoneroNodeResponse, CheckSeedArgs,
CheckSeedResponse, ExportBitcoinWalletArgs, GetCurrentSwapArgs, GetDataDirArgs,
GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, GetPendingApprovalsResponse,
GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs,
ResolveApprovalArgs, ResumeSwapArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs,
GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, GetMoneroBalanceArgs,
GetMoneroHistoryArgs, GetMoneroMainAddressArgs, GetMoneroSyncProgressArgs,
GetPendingApprovalsResponse, GetRestoreHeightArgs, GetSwapInfoArgs,
GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs,
RejectApprovalArgs, RejectApprovalResponse, ResolveApprovalArgs, ResumeSwapArgs,
SendMoneroArgs, SetRestoreHeightArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs,
},
tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings},
Context, ContextBuilder,
@ -64,11 +66,11 @@ macro_rules! tauri_command {
($fn_name:ident, $request_name:ident) => {
#[tauri::command]
async fn $fn_name(
context: tauri::State<'_, RwLock<State>>,
state: tauri::State<'_, State>,
args: $request_name,
) -> Result<<$request_name as swap::cli::api::request::Request>::Response, String> {
// Throw error if context is not available
let context = context.read().await.try_get_context()?;
let context = state.try_get_context()?;
<$request_name as swap::cli::api::request::Request>::request(args, context)
.await
@ -78,10 +80,10 @@ macro_rules! tauri_command {
($fn_name:ident, $request_name:ident, no_args) => {
#[tauri::command]
async fn $fn_name(
context: tauri::State<'_, RwLock<State>>,
state: tauri::State<'_, State>,
) -> Result<<$request_name as swap::cli::api::request::Request>::Response, String> {
// Throw error if context is not available
let context = context.read().await.try_get_context()?;
let context = state.try_get_context()?;
<$request_name as swap::cli::api::request::Request>::request($request_name {}, context)
.await
@ -92,7 +94,7 @@ macro_rules! tauri_command {
/// Represents the shared Tauri state. It is accessed by Tauri commands
struct State {
pub context: Option<Arc<Context>>,
pub context: RwLock<Option<Arc<Context>>>,
pub handle: TauriHandle,
}
@ -100,22 +102,17 @@ impl State {
/// Creates a new State instance with no Context
fn new(handle: TauriHandle) -> Self {
Self {
context: None,
context: RwLock::new(None),
handle,
}
}
/// Sets the context for the application state
/// This is typically called after the Context has been initialized
/// in the setup function
fn set_context(&mut self, context: impl Into<Option<Arc<Context>>>) {
self.context = context.into();
}
/// Attempts to retrieve the context
/// Returns an error if the context is not available
fn try_get_context(&self) -> Result<Arc<Context>, String> {
self.context
.try_read()
.map_err(|_| "Context is being modified".to_string())?
.clone()
.ok_or("Context not available".to_string())
}
@ -149,8 +146,8 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
// We need to set a value for the Tauri state right at the start
// If we don't do this, Tauri commands will panic at runtime if no value is present
let handle = TauriHandle::new(app_handle.clone());
let state = RwLock::new(State::new(handle));
app_handle.manage::<RwLock<State>>(state);
let state = State::new(handle);
app_handle.manage::<State>(state);
Ok(())
}
@ -203,8 +200,16 @@ pub fn run() {
resolve_approval_request,
redact,
save_txt_files,
get_monero_history,
get_monero_main_address,
get_monero_balance,
send_monero,
get_monero_sync_progress,
check_seed,
get_pending_approvals,
set_monero_restore_height,
reject_approval_request,
get_restore_height
])
.setup(setup)
.build(tauri::generate_context!())
@ -215,18 +220,17 @@ pub fn run() {
// This is necessary to among other things stop the monero-wallet-rpc process
// If the application is forcibly closed, this may not be called.
// TODO: fix that
let context = app.state::<RwLock<State>>().inner().try_read();
let state = app.state::<State>();
let context_to_cleanup = if let Ok(context_lock) = state.context.try_read() {
context_lock.clone()
} else {
println!("Failed to acquire lock on context");
None
};
match context {
Ok(context) => {
if let Some(context) = context.context.as_ref() {
if let Err(err) = context.cleanup() {
println!("Cleanup failed {}", err);
}
}
}
Err(err) => {
println!("Failed to acquire lock on context: {}", err);
if let Some(context) = context_to_cleanup {
if let Err(err) = context.cleanup() {
println!("Cleanup failed {}", err);
}
}
}
@ -246,6 +250,7 @@ tauri_command!(get_logs, GetLogsArgs);
tauri_command!(list_sellers, ListSellersArgs);
tauri_command!(cancel_and_refund, CancelAndRefundArgs);
tauri_command!(redact, RedactArgs);
tauri_command!(send_monero, SendMoneroArgs);
// These commands require no arguments
tauri_command!(get_wallet_descriptor, ExportBitcoinWalletArgs, no_args);
@ -254,19 +259,25 @@ tauri_command!(get_swap_info, GetSwapInfoArgs);
tauri_command!(get_swap_infos_all, GetSwapInfosAllArgs, no_args);
tauri_command!(get_history, GetHistoryArgs, no_args);
tauri_command!(get_monero_addresses, GetMoneroAddressesArgs, no_args);
tauri_command!(get_monero_history, GetMoneroHistoryArgs, no_args);
tauri_command!(get_current_swap, GetCurrentSwapArgs, no_args);
tauri_command!(set_monero_restore_height, SetRestoreHeightArgs);
tauri_command!(get_restore_height, GetRestoreHeightArgs, no_args);
tauri_command!(get_monero_main_address, GetMoneroMainAddressArgs, no_args);
tauri_command!(get_monero_balance, GetMoneroBalanceArgs, no_args);
tauri_command!(get_monero_sync_progress, GetMoneroSyncProgressArgs, no_args);
/// Here we define Tauri commands whose implementation is not delegated to the Request trait
#[tauri::command]
async fn is_context_available(context: tauri::State<'_, RwLock<State>>) -> Result<bool, String> {
async fn is_context_available(state: tauri::State<'_, State>) -> Result<bool, String> {
// TODO: Here we should return more information about status of the context (e.g. initializing, failed)
Ok(context.read().await.try_get_context().is_ok())
Ok(state.try_get_context().is_ok())
}
#[tauri::command]
async fn check_monero_node(
args: CheckMoneroNodeArgs,
_: tauri::State<'_, RwLock<State>>,
_: tauri::State<'_, State>,
) -> Result<CheckMoneroNodeResponse, String> {
args.request().await.to_string_result()
}
@ -274,7 +285,7 @@ async fn check_monero_node(
#[tauri::command]
async fn check_electrum_node(
args: CheckElectrumNodeArgs,
_: tauri::State<'_, RwLock<State>>,
_: tauri::State<'_, State>,
) -> Result<CheckElectrumNodeResponse, String> {
args.request().await.to_string_result()
}
@ -282,7 +293,7 @@ async fn check_electrum_node(
#[tauri::command]
async fn check_seed(
args: CheckSeedArgs,
_: tauri::State<'_, RwLock<State>>,
_: tauri::State<'_, State>,
) -> Result<CheckSeedResponse, String> {
args.request().await.to_string_result()
}
@ -291,10 +302,7 @@ async fn check_seed(
// This is independent of the context to ensure the user can open the directory even if the context cannot
// be initialized (for troubleshooting purposes)
#[tauri::command]
async fn get_data_dir(
args: GetDataDirArgs,
_: tauri::State<'_, RwLock<State>>,
) -> Result<String, String> {
async fn get_data_dir(args: GetDataDirArgs, _: tauri::State<'_, State>) -> Result<String, String> {
Ok(data::data_dir_from(None, args.is_testnet)
.to_string_result()?
.to_string_lossy()
@ -349,26 +357,46 @@ async fn save_txt_files(
#[tauri::command]
async fn resolve_approval_request(
args: ResolveApprovalArgs,
state: tauri::State<'_, RwLock<State>>,
state: tauri::State<'_, State>,
) -> Result<(), String> {
println!("Resolving approval request");
let lock = state.read().await;
let request_id = args
.request_id
.parse()
.map_err(|e| format!("Invalid request ID '{}': {}", args.request_id, e))?;
lock.handle
.resolve_approval(args.request_id.parse().unwrap(), args.accept)
state
.handle
.resolve_approval(request_id, args.accept)
.await
.to_string_result()?;
Ok(())
}
#[tauri::command]
async fn reject_approval_request(
args: RejectApprovalArgs,
state: tauri::State<'_, State>,
) -> Result<RejectApprovalResponse, String> {
let request_id = args
.request_id
.parse()
.map_err(|e| format!("Invalid request ID '{}': {}", args.request_id, e))?;
state
.handle
.reject_approval(request_id)
.await
.to_string_result()?;
Ok(RejectApprovalResponse { success: true })
}
#[tauri::command]
async fn get_pending_approvals(
state: tauri::State<'_, RwLock<State>>,
state: tauri::State<'_, State>,
) -> Result<GetPendingApprovalsResponse, String> {
let approvals = state
.read()
.await
.handle
.get_pending_approvals()
.await
@ -382,41 +410,21 @@ async fn get_pending_approvals(
async fn initialize_context(
settings: TauriSettings,
testnet: bool,
state: tauri::State<'_, RwLock<State>>,
state: tauri::State<'_, State>,
) -> Result<(), String> {
// When the app crashes, the monero-wallet-rpc process may not be killed
// This can lead to issues when the app is restarted
// because the monero-wallet-rpc has a lock on the wallet
// this will prevent the newly spawned instance from opening the wallet
// To fix this, we kill any running monero-wallet-rpc processes
let sys = sysinfo::System::new_with_specifics(
sysinfo::RefreshKind::new().with_processes(sysinfo::ProcessRefreshKind::new()),
);
// Lock at the beginning - fail immediately if already locked
let mut context_lock = state
.context
.try_write()
.map_err(|_| "Context is already being initialized".to_string())?;
for (pid, process) in sys.processes() {
if process
.name()
.to_string_lossy()
.starts_with("monero-wallet-rpc")
{
#[cfg(not(debug_assertions))]
{
println!("Killing monero-wallet-rpc process with pid: {}", pid);
process.kill();
}
#[cfg(debug_assertions)]
println!("Would kill monero-wallet-rpc process with pid: {}", pid);
}
// Fail if the context is already initialized
if context_lock.is_some() {
return Err("Context is already initialized".to_string());
}
// Get app handle and create a Tauri handle
let tauri_handle = state
.try_read()
.context("Context is already being initialized")
.to_string_result()?
.handle
.clone();
// Get tauri handle from the state
let tauri_handle = state.handle.clone();
// Notify frontend that the context is being initialized
tauri_handle.emit_context_init_progress_event(TauriContextStatusEvent::Initializing);
@ -436,11 +444,7 @@ async fn initialize_context(
match context_result {
Ok(context_instance) => {
state
.try_write()
.context("Context is already being initialized")
.to_string_result()?
.set_context(Arc::new(context_instance));
*context_lock = Some(Arc::new(context_instance));
tracing::info!("Context initialized");