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

115
Cargo.lock generated
View file

@ -1978,16 +1978,6 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-epoch" name = "crossbeam-epoch"
version = "0.9.18" version = "0.9.18"
@ -6078,6 +6068,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"backoff", "backoff",
"chrono",
"cmake", "cmake",
"cxx", "cxx",
"cxx-build", "cxx-build",
@ -6347,15 +6338,6 @@ dependencies = [
"instant", "instant",
] ]
[[package]]
name = "ntapi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.46.0" version = "0.46.0"
@ -7839,26 +7821,6 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rayon"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]] [[package]]
name = "rcgen" name = "rcgen"
version = "0.11.3" version = "0.11.3"
@ -9829,6 +9791,7 @@ dependencies = [
"tempfile", "tempfile",
"testcontainers", "testcontainers",
"thiserror 1.0.69", "thiserror 1.0.69",
"throttle",
"time 0.3.41", "time 0.3.41",
"tokio", "tokio",
"tokio-tar", "tokio-tar",
@ -9980,20 +9943,6 @@ dependencies = [
"syn 2.0.104", "syn 2.0.104",
] ]
[[package]]
name = "sysinfo"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af"
dependencies = [
"core-foundation-sys",
"libc",
"memchr",
"ntapi",
"rayon",
"windows 0.57.0",
]
[[package]] [[package]]
name = "system-configuration" name = "system-configuration"
version = "0.6.1" version = "0.6.1"
@ -10630,6 +10579,13 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "throttle"
version = "0.1.0"
dependencies = [
"tracing",
]
[[package]] [[package]]
name = "tiff" name = "tiff"
version = "0.9.1" version = "0.9.1"
@ -12412,7 +12368,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"swap", "swap",
"sysinfo",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-cli", "tauri-plugin-cli",
@ -12956,8 +12911,8 @@ dependencies = [
"webview2-com-sys", "webview2-com-sys",
"windows 0.61.3", "windows 0.61.3",
"windows-core 0.61.2", "windows-core 0.61.2",
"windows-implement 0.60.0", "windows-implement",
"windows-interface 0.59.1", "windows-interface",
] ]
[[package]] [[package]]
@ -13060,16 +13015,6 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
dependencies = [
"windows-core 0.57.0",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows" name = "windows"
version = "0.61.3" version = "0.61.3"
@ -13102,26 +13047,14 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-core"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
dependencies = [
"windows-implement 0.57.0",
"windows-interface 0.57.0",
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.61.2" version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [ dependencies = [
"windows-implement 0.60.0", "windows-implement",
"windows-interface 0.59.1", "windows-interface",
"windows-link", "windows-link",
"windows-result 0.3.4", "windows-result 0.3.4",
"windows-strings", "windows-strings",
@ -13138,17 +13071,6 @@ dependencies = [
"windows-threading", "windows-threading",
] ]
[[package]]
name = "windows-implement"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]] [[package]]
name = "windows-implement" name = "windows-implement"
version = "0.60.0" version = "0.60.0"
@ -13160,17 +13082,6 @@ dependencies = [
"syn 2.0.104", "syn 2.0.104",
] ]
[[package]]
name = "windows-interface"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]] [[package]]
name = "windows-interface" name = "windows-interface"
version = "0.59.1" version = "0.59.1"

View file

@ -1,6 +1,6 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = [ "electrum-pool", "monero-rpc", "monero-rpc-pool", "monero-sys", "monero-seed", "src-tauri", "swap", "swap-env", "swap-fs", "swap-feed", "swap-serde"] members = [ "electrum-pool", "monero-rpc", "monero-rpc-pool", "monero-sys", "monero-seed", "src-tauri", "swap", "swap-env", "swap-fs", "swap-feed", "swap-serde", "throttle"]
[workspace.dependencies] [workspace.dependencies]
anyhow = "1" anyhow = "1"
@ -11,17 +11,17 @@ futures = { version = "0.3", default-features = false, features = ["std"] }
tracing = { version = "0.1", features = ["attributes"] } tracing = { version = "0.1", features = ["attributes"] }
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "ansi", "env-filter", "time", "tracing-log", "json"] } tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "ansi", "env-filter", "time", "tracing-log", "json"] }
bitcoin = { version = "0.32", features = ["rand", "serde"] } bitcoin = { version = "0.32", features = ["rand", "serde"] }
hex = "0.4"
libp2p = { version = "0.53.2" }
monero = { version = "0.12", features = ["serde_support"] } monero = { version = "0.12", features = ["serde_support"] }
rand = "0.8" rand = "0.8"
uuid = { version = "1", features = ["v4"] }
typeshare = "1.0"
thiserror = "1"
reqwest = { version = "0.12", default-features = false, features = ["json"] } reqwest = { version = "0.12", default-features = false, features = ["json"] }
rust_decimal = { version = "1", features = ["serde-float"] } rust_decimal = { version = "1", features = ["serde-float"] }
rust_decimal_macros = "1" rust_decimal_macros = "1"
libp2p = { version = "0.53.2" } thiserror = "1"
typeshare = "1.0"
url = { version = "2", features = ["serde"] } url = { version = "2", features = ["serde"] }
hex = "0.4" uuid = { version = "1", features = ["v4"] }
[patch.crates-io] [patch.crates-io]
# patch until new release https://github.com/thomaseizinger/rust-jsonrpc-client/pull/51 # patch until new release https://github.com/thomaseizinger/rust-jsonrpc-client/pull/51

View file

@ -26,7 +26,7 @@ test-ffi-address:
# Start the Tauri app # Start the Tauri app
tauri: tauri:
cd src-tauri && cargo tauri dev --no-watch -- -- --testnet cd src-tauri && cargo tauri dev --no-watch --verbose -- -- --testnet
tauri-mainnet: tauri-mainnet:
cd src-tauri && cargo tauri dev --no-watch cd src-tauri && cargo tauri dev --no-watch
@ -105,4 +105,4 @@ prepare_mac_os_brew_dependencies:
# Takes a crate (e.g monero-rpc-pool) and uses code2prompt to copy to clipboard # Takes a crate (e.g monero-rpc-pool) and uses code2prompt to copy to clipboard
# E.g code2prompt . --exclude "*.lock" --exclude ".sqlx/*" --exclude "target" # E.g code2prompt . --exclude "*.lock" --exclude ".sqlx/*" --exclude "target"
code2prompt_single_crate crate: code2prompt_single_crate crate:
cd {{crate}} && code2prompt . --exclude "*.lock" --exclude ".sqlx/*" --exclude "target" cd {{crate}} && code2prompt . --exclude "*.lock" --exclude ".sqlx/*" --exclude "target"

View file

@ -504,11 +504,7 @@ impl MoneroWallet {
/// Sweep multiple addresses with different ratios /// Sweep multiple addresses with different ratios
/// If the address is `None`, the address will be set to the primary address of the /// If the address is `None`, the address will be set to the primary address of the
/// main wallet. /// main wallet.
pub async fn sweep_multi( pub async fn sweep_multi(&self, addresses: &[Address], ratios: &[f64]) -> Result<TxReceipt> {
&self,
addresses: &[Address],
ratios: &[f64],
) -> Result<TxReceipt> {
tracing::info!("`{}` sweeping multi ({:?})", self.name, ratios); tracing::info!("`{}` sweeping multi ({:?})", self.name, ratios);
self.balance().await?; self.balance().await?;

View file

@ -17,7 +17,7 @@ impl Database {
info!("Created application data directory: {}", data_dir.display()); info!("Created application data directory: {}", data_dir.display());
} }
let db_path = data_dir.join("nodes_v2.db"); let db_path = data_dir.join("nodes_v3.db");
info!("Using database at {}", db_path.display()); info!("Using database at {}", db_path.display());

View file

@ -39,6 +39,7 @@ use proxy::{proxy_handler, stats_handler};
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub node_pool: Arc<NodePool>, pub node_pool: Arc<NodePool>,
pub http_client: reqwest::Client,
} }
/// Manages background tasks for the RPC pool /// Manages background tasks for the RPC pool
@ -59,6 +60,12 @@ pub struct ServerInfo {
pub host: String, pub host: String,
} }
impl Into<String> for ServerInfo {
fn into(self) -> String {
format!("http://{}:{}", self.host, self.port)
}
}
async fn create_app_with_receiver( async fn create_app_with_receiver(
config: Config, config: Config,
network: Network, network: Network,
@ -97,7 +104,20 @@ async fn create_app_with_receiver(
status_update_handle, status_update_handle,
}; };
let app_state = AppState { node_pool }; // Create shared HTTP client with connection pooling and keep-alive
let http_client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.danger_accept_invalid_certs(true)
.pool_max_idle_per_host(10)
.pool_idle_timeout(std::time::Duration::from_secs(90))
.tcp_keepalive(std::time::Duration::from_secs(60))
.build()
.expect("Failed to create HTTP client");
let app_state = AppState {
node_pool,
http_client,
};
// Build the app // Build the app
let app = Router::new() let app = Router::new()

View file

@ -11,11 +11,16 @@ use uuid::Uuid;
use crate::AppState; use crate::AppState;
fn display_node(node: &(String, String, i64)) -> String {
format!("{}://{}:{}", node.0, node.1, node.2)
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum HandlerError { enum HandlerError {
NoNodes, NoNodes,
PoolError(String), PoolError(String),
RequestError(String), RequestError(String),
JsonRpcError(String),
AllRequestsFailed(Vec<(String, String)>), AllRequestsFailed(Vec<(String, String)>),
} }
@ -25,6 +30,7 @@ impl std::fmt::Display for HandlerError {
HandlerError::NoNodes => write!(f, "No nodes available"), HandlerError::NoNodes => write!(f, "No nodes available"),
HandlerError::PoolError(msg) => write!(f, "Pool error: {}", msg), HandlerError::PoolError(msg) => write!(f, "Pool error: {}", msg),
HandlerError::RequestError(msg) => write!(f, "Request error: {}", msg), HandlerError::RequestError(msg) => write!(f, "Request error: {}", msg),
HandlerError::JsonRpcError(msg) => write!(f, "JSON-RPC error: {}", msg),
HandlerError::AllRequestsFailed(errors) => { HandlerError::AllRequestsFailed(errors) => {
write!(f, "All requests failed: [")?; write!(f, "All requests failed: [")?;
for (i, (node, error)) in errors.iter().enumerate() { for (i, (node, error)) in errors.iter().enumerate() {
@ -60,17 +66,13 @@ fn extract_jsonrpc_method(body: &[u8]) -> Option<String> {
} }
async fn raw_http_request( async fn raw_http_request(
client: &reqwest::Client,
node_url: (String, String, i64), node_url: (String, String, i64),
path: &str, path: &str,
method: &str, method: &str,
headers: &HeaderMap, headers: &HeaderMap,
body: Option<&[u8]>, body: Option<&[u8]>,
) -> Result<Response, HandlerError> { ) -> Result<Response, HandlerError> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| HandlerError::RequestError(format!("{:#?}", e)))?;
let (scheme, host, port) = &node_url; let (scheme, host, port) = &node_url;
let url = format!("{}://{}:{}{}", scheme, host, port, path); let url = format!("{}://{}:{}{}", scheme, host, port, path);
@ -172,6 +174,7 @@ async fn record_failure(state: &AppState, scheme: &str, host: &str, port: i64) {
} }
async fn single_raw_request( async fn single_raw_request(
client: &reqwest::Client,
node_url: (String, String, i64), node_url: (String, String, i64),
path: &str, path: &str,
method: &str, method: &str,
@ -180,7 +183,7 @@ async fn single_raw_request(
) -> Result<(Response, (String, String, i64), f64), HandlerError> { ) -> Result<(Response, (String, String, i64), f64), HandlerError> {
let start_time = Instant::now(); let start_time = Instant::now();
match raw_http_request(node_url.clone(), path, method, headers, body).await { match raw_http_request(client, node_url.clone(), path, method, headers, body).await {
Ok(response) => { Ok(response) => {
let elapsed = start_time.elapsed(); let elapsed = start_time.elapsed();
let latency_ms = elapsed.as_millis() as f64; let latency_ms = elapsed.as_millis() as f64;
@ -195,7 +198,7 @@ async fn single_raw_request(
.map_err(|e| HandlerError::RequestError(format!("{:#?}", e)))?; .map_err(|e| HandlerError::RequestError(format!("{:#?}", e)))?;
if is_jsonrpc_error(&body_bytes) { if is_jsonrpc_error(&body_bytes) {
return Err(HandlerError::RequestError("JSON-RPC error".to_string())); return Err(HandlerError::JsonRpcError("JSON-RPC error".to_string()));
} }
// Reconstruct response with the body we consumed // Reconstruct response with the body we consumed
@ -225,6 +228,7 @@ async fn sequential_requests(
body: Option<&[u8]>, body: Option<&[u8]>,
) -> Result<Response, HandlerError> { ) -> Result<Response, HandlerError> {
const POOL_SIZE: usize = 20; const POOL_SIZE: usize = 20;
const MAX_JSONRPC_ERRORS: usize = 3;
// Extract JSON-RPC method for better logging // Extract JSON-RPC method for better logging
let jsonrpc_method = if path == "/json_rpc" { let jsonrpc_method = if path == "/json_rpc" {
@ -238,7 +242,7 @@ async fn sequential_requests(
}; };
let mut tried_nodes = 0; let mut tried_nodes = 0;
let mut collected_errors: Vec<(String, String)> = Vec::new(); let mut collected_errors: Vec<((String, String, i64), HandlerError)> = Vec::new();
// Get the pool of nodes // Get the pool of nodes
let available_pool = { let available_pool = {
@ -283,7 +287,16 @@ async fn sequential_requests(
), ),
} }
match single_raw_request(node.clone(), path, method, headers, body).await { match single_raw_request(
&state.http_client,
node.clone(),
path,
method,
headers,
body,
)
.await
{
Ok((response, winning_node, latency_ms)) => { Ok((response, winning_node, latency_ms)) => {
let (scheme, host, port) = &winning_node; let (scheme, host, port) = &winning_node;
let winning_node_display = format!("{}://{}:{}", scheme, host, port); let winning_node_display = format!("{}://{}:{}", scheme, host, port);
@ -304,24 +317,61 @@ async fn sequential_requests(
return Ok(response); return Ok(response);
} }
Err(e) => { Err(e) => {
collected_errors.push((node_display.clone(), e.to_string())); collected_errors.push((node.clone(), e.clone()));
debug!( debug!(
"Request failed with node {} with error {} - trying next node...", "Request failed with node {}: {} - checking if we should fail fast...",
node_display, e node_display, e
); );
record_failure(state, &node.0, &node.1, node.2).await; // Count JSON-RPC errors by checking through all collected errors (type-safe)
let jsonrpc_error_count = collected_errors
.iter()
.filter(|(_, error)| matches!(error, HandlerError::JsonRpcError(_)))
.count();
// Fail fast after MAX_JSONRPC_ERRORS JSON-RPC errors
if jsonrpc_error_count >= MAX_JSONRPC_ERRORS {
match &jsonrpc_method {
Some(rpc_method) => error!(
"Failing fast after {} JSON-RPC errors for {} request (JSON-RPC: {}). These are likely request-specific issues that won't resolve on other servers.",
jsonrpc_error_count, method, rpc_method
),
None => error!(
"Failing fast after {} JSON-RPC errors for {} request. These are likely request-specific issues that won't resolve on other servers.",
jsonrpc_error_count, method
),
}
// Record all non-JSON-RPC errors as failures
for (node, error) in collected_errors.iter() {
if !matches!(error, HandlerError::JsonRpcError(_)) {
record_failure(state, &node.0, &node.1, node.2).await;
}
}
return Err(HandlerError::AllRequestsFailed(
collected_errors
.into_iter()
.map(|(node, error)| (display_node(&node), error.to_string()))
.collect(),
));
}
continue; continue;
} }
} }
} }
// Record failures for all nodes that were tried
for (node, _) in collected_errors.iter() {
record_failure(state, &node.0, &node.1, node.2).await;
}
// Log detailed error information // Log detailed error information
let detailed_errors: Vec<String> = collected_errors let detailed_errors: Vec<String> = collected_errors
.iter() .iter()
.map(|(node, error)| format!("{}: {}", node, error)) .map(|(node, error)| format!("{}: {}", display_node(node), error))
.collect(); .collect();
match &jsonrpc_method { match &jsonrpc_method {
@ -340,7 +390,12 @@ async fn sequential_requests(
), ),
} }
Err(HandlerError::AllRequestsFailed(collected_errors)) Err(HandlerError::AllRequestsFailed(
collected_errors
.into_iter()
.map(|(node, error)| (display_node(&node), error.to_string()))
.collect(),
))
} }
/// Forward a request to the node pool, returning either a successful response or a simple /// Forward a request to the node pool, returning either a successful response or a simple
@ -400,6 +455,15 @@ async fn proxy_request(
} }
}) })
} }
HandlerError::JsonRpcError(msg) => {
json!({
"error": "JSON-RPC error",
"details": {
"type": "JsonRpcError",
"message": msg
}
})
}
}; };
Response::builder() Response::builder()

View file

@ -0,0 +1,23 @@
{
"db_name": "SQLite",
"query": "\n SELECT wallet_path, last_opened_at\n FROM recent_wallets \n ORDER BY last_opened_at DESC\n LIMIT ?\n ",
"describe": {
"columns": [
{
"name": "wallet_path",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "last_opened_at",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [false, false]
},
"hash": "7e58428584d28a238ab37a83662b88afcef6fc5246f11c85a35869f79da61c34"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO recent_wallets (wallet_path, last_opened_at)\n VALUES (?, ?)\n ON CONFLICT(wallet_path) DO UPDATE SET last_opened_at = excluded.last_opened_at\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "a679f789c90ede34cd840d23d90087520dcf1777fdf4cc3ed7aab0c9d70d060c"
}

View file

@ -6,6 +6,7 @@ edition = "2021"
[dependencies] [dependencies]
anyhow = { workspace = true } anyhow = { workspace = true }
backoff = { version = "0.4.0", features = ["futures", "tokio"] } backoff = { version = "0.4.0", features = ["futures", "tokio"] }
chrono = { version = "0.4", features = ["serde"] }
cxx = "1.0.137" cxx = "1.0.137"
monero = { workspace = true } monero = { workspace = true }
serde = { workspace = true } serde = { workspace = true }

View file

@ -0,0 +1,9 @@
-- Add migration script here
CREATE TABLE recent_wallets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
wallet_path TEXT UNIQUE NOT NULL,
last_opened_at TEXT NOT NULL
);
CREATE INDEX idx_recent_wallets_last_opened ON recent_wallets(last_opened_at DESC);

View file

@ -0,0 +1,56 @@
#!/bin/bash
# regenerate_sqlx_cache.sh
#
# Script to regenerate SQLx query cache for monero-rpc-pool
#
# This script:
# 1. Creates a temporary SQLite database in a temp directory
# 2. Runs all database migrations to set up the schema
# 3. Regenerates the SQLx query cache (.sqlx directory)
# 4. Cleans up temporary files automatically
#
# Usage:
# ./regenerate_sqlx_cache.sh
#
# Requirements:
# - cargo and sqlx-cli must be installed
# - Must be run from the monero-rpc-pool directory
# - migrations/ directory must exist with valid migration files
#
# The generated .sqlx directory should be committed to version control
# to enable offline compilation without requiring DATABASE_URL.
set -e # Exit on any error
echo "🔄 Regenerating SQLx query cache..."
# Create a temporary directory for the database
TEMP_DIR=$(mktemp -d)
TEMP_DB="$TEMP_DIR/temp_sqlx_cache.sqlite"
DATABASE_URL="sqlite:$TEMP_DB"
echo "📁 Using temporary database: $TEMP_DB"
# Function to cleanup on exit
cleanup() {
echo "🧹 Cleaning up temporary files..."
rm -rf "$TEMP_DIR"
}
trap cleanup EXIT
# Export DATABASE_URL for sqlx commands
export DATABASE_URL
echo "🗄️ Creating database..."
cargo sqlx database create
echo "🔄 Running migrations..."
cargo sqlx migrate run
echo "⚡ Preparing SQLx query cache..."
cargo sqlx prepare
echo "✅ SQLx query cache regenerated successfully!"
echo "📝 The .sqlx directory has been updated with the latest query metadata."
echo "💡 Make sure to commit the .sqlx directory to version control."

View file

@ -16,7 +16,9 @@
* - CXX doesn't support static methods as yet, so we define free functions here that * - CXX doesn't support static methods as yet, so we define free functions here that
* simply call the appropriate static methods. * simply call the appropriate static methods.
* - CXX also doesn't support returning strings by value from C++ to Rust, so we wrap * - CXX also doesn't support returning strings by value from C++ to Rust, so we wrap
* those in a unique_ptr. * those in a unique_ptr/shared_ptr.
* ATTENTION: unique_ptr will delete the object on drop,
* verify that you actually OWN the object before wrapping it in a unique_ptr. Use shared_ptr otherwise
* - CXX doesn't support optional arguments, so we make thin wrapper functions that either * - CXX doesn't support optional arguments, so we make thin wrapper functions that either
* take the argument or not. * take the argument or not.
* *
@ -58,6 +60,26 @@ namespace Monero
auto addr = wallet.address(account_index, address_index); auto addr = wallet.address(account_index, address_index);
return std::make_unique<std::string>(addr); return std::make_unique<std::string>(addr);
} }
inline void rescanBlockchainAsync(Wallet &wallet)
{
wallet.rescanBlockchainAsync();
}
inline void pauseRefresh(Wallet &wallet)
{
wallet.pauseRefresh();
}
inline void stop(Wallet &wallet)
{
wallet.stop();
}
inline void startRefresh(Wallet &wallet)
{
wallet.startRefresh();
}
/** /**
* Same as for [`address`] * Same as for [`address`]
@ -223,6 +245,16 @@ namespace Monero
return std::make_unique<std::vector<std::string>>(tx.txid()); return std::make_unique<std::vector<std::string>>(tx.txid());
} }
inline uint64_t pendingTransactionFee(const PendingTransaction &tx)
{
return tx.fee();
}
inline uint64_t pendingTransactionAmount(const PendingTransaction &tx)
{
return tx.amount();
}
inline std::unique_ptr<std::string> walletFilename(const Wallet &wallet) inline std::unique_ptr<std::string> walletFilename(const Wallet &wallet)
{ {
return std::make_unique<std::string>(wallet.filename()); return std::make_unique<std::string>(wallet.filename());
@ -234,6 +266,156 @@ namespace Monero
{ {
v.push_back(s); v.push_back(s);
} }
/**
* Get the hash of a transaction from TransactionInfo.
*/
inline std::unique_ptr<std::string> transactionInfoHash(const TransactionInfo &tx_info)
{
return std::make_unique<std::string>(tx_info.hash());
}
/**
* Get the timestamp of a transaction from TransactionInfo.
*/
inline uint64_t transactionInfoTimestamp(const TransactionInfo &tx_info)
{
return static_cast<uint64_t>(tx_info.timestamp());
}
// bridge.h
#pragma once
#include <string>
#include <cstdint>
#include "wallet/api/wallet2_api.h"
using CB_StringU64 = uintptr_t;
using CB_U64 = uintptr_t;
using CB_Void = uintptr_t;
using CB_Reorg = uintptr_t;
using CB_String = uintptr_t;
using CB_GetPassword = uintptr_t;
class FunctionBasedListener final : public Monero::WalletListener {
public:
FunctionBasedListener(
CB_StringU64 on_spent,
CB_StringU64 on_received,
CB_StringU64 on_unconfirmed_received,
CB_U64 on_new_block,
CB_Void on_updated,
CB_Void on_refreshed,
CB_Reorg on_reorg,
CB_String on_pool_tx_removed,
CB_GetPassword on_get_password)
:
on_spent_(on_spent),
on_received_(on_received),
on_unconfirmed_received_(on_unconfirmed_received),
on_new_block_(on_new_block),
on_updated_(on_updated),
on_refreshed_(on_refreshed),
on_reorg_(on_reorg),
on_pool_tx_removed_(on_pool_tx_removed),
on_get_password_(on_get_password) {}
void moneySpent(const std::string& txid, uint64_t amt) override {
if (on_spent_) {
auto* spent = reinterpret_cast<void(*)(const std::string&, uint64_t)>(on_spent_);
spent(txid, amt);
}
}
void moneyReceived(const std::string& txid, uint64_t amt) override
{ if (on_received_) {
auto* received = reinterpret_cast<void(*)(const std::string&, uint64_t)>(on_received_);
received(txid, amt);
}
}
void unconfirmedMoneyReceived(const std::string& txid, uint64_t amt) override
{ if (on_unconfirmed_received_) {
auto* unconfirmed_received = reinterpret_cast<void(*)(const std::string&, uint64_t)>(on_unconfirmed_received_);
unconfirmed_received(txid, amt);
}
}
void newBlock(uint64_t h) override
{ if (on_new_block_) {
auto* new_block = reinterpret_cast<void(*)(uint64_t)>(on_new_block_);
new_block(h);
}
}
void updated() override
{
if (on_updated_) {
auto* updated = reinterpret_cast<void(*)()>(on_updated_);
updated();
}
}
void refreshed() override
{ if (on_refreshed_) {
auto* refreshed = reinterpret_cast<void(*)()>(on_refreshed_);
refreshed();
}
}
void onReorg(uint64_t h, uint64_t d, size_t t) override
{ if (on_reorg_) {
auto* reorg = reinterpret_cast<void(*)(uint64_t, uint64_t, size_t)>(on_reorg_);
reorg(h, d, t);
}
}
void onPoolTxRemoved(const std::string& txid) override
{ if (on_pool_tx_removed_) {
auto* pool_tx_removed = reinterpret_cast<void(*)(const std::string&)>(on_pool_tx_removed_);
pool_tx_removed(txid);
}
}
optional<std::string> onGetPassword(const char* reason) override {
if (on_get_password_) {
auto* get_password = reinterpret_cast<const char*(*)(const std::string&)>(on_get_password_);
return std::string(get_password(reason));
}
return optional<std::string>();
}
private:
CB_StringU64 on_spent_;
CB_StringU64 on_received_;
CB_StringU64 on_unconfirmed_received_;
CB_U64 on_new_block_;
CB_Void on_updated_;
CB_Void on_refreshed_;
CB_Reorg on_reorg_;
CB_String on_pool_tx_removed_;
CB_GetPassword on_get_password_;
};
extern "C" {
WalletListener* create_listener(
CB_StringU64 on_spent,
CB_StringU64 on_received,
CB_StringU64 on_unconfirmed_received,
CB_U64 on_new_block,
CB_Void on_updated,
CB_Void on_refreshed,
CB_Reorg on_reorg,
CB_String on_pool_tx_removed,
CB_GetPassword on_get_password)
{
return new FunctionBasedListener(
on_spent,on_received,on_unconfirmed_received,on_new_block,
on_updated,on_refreshed,on_reorg,on_pool_tx_removed,on_get_password);
}
void destroy_listener(FunctionBasedListener* p) { delete p; }
}
} }
#include "easylogging++.h" #include "easylogging++.h"
@ -359,3 +541,64 @@ using StringMap = std::map<String, String>;
using StringVec = std::vector<String>; using StringVec = std::vector<String>;
static std::pair<StringMap, StringVec> _monero_sys_pair_instantiation; static std::pair<StringMap, StringVec> _monero_sys_pair_instantiation;
namespace Monero {
// Adapter class that forwards Monero::WalletListener callbacks to Rust
class RustListenerAdapter final : public Monero::WalletListener {
public:
explicit RustListenerAdapter(rust::Box<wallet_listener::WalletListenerBox> listener)
: inner_(std::move(listener)) {}
// --- Required overrides ------------------------------------------------
void moneySpent(const std::string &txid, uint64_t amount) override {
wallet_listener::money_spent(*inner_, txid, amount);
}
void moneyReceived(const std::string &txid, uint64_t amount) override {
wallet_listener::money_received(*inner_, txid, amount);
}
void unconfirmedMoneyReceived(const std::string &txid, uint64_t amount) override {
wallet_listener::unconfirmed_money_received(*inner_, txid, amount);
}
void newBlock(uint64_t height) override {
wallet_listener::new_block(*inner_, height);
}
void updated() override {
wallet_listener::updated(*inner_);
}
void refreshed() override {
wallet_listener::refreshed(*inner_);
}
void onReorg(std::uint64_t height, std::uint64_t blocks_detached, std::size_t transfers_detached) override {
wallet_listener::on_reorg(*inner_, height, blocks_detached, transfers_detached);
}
optional<std::string> onGetPassword(const char * /*reason*/) override {
return optional<std::string>(); // Not implemented
}
void onPoolTxRemoved(const std::string &txid) override {
wallet_listener::pool_tx_removed(*inner_, txid);
}
private:
rust::Box<wallet_listener::WalletListenerBox> inner_;
};
} // namespace Monero
namespace wallet_listener {
Monero::WalletListener* create_rust_listener_adapter(rust::Box<WalletListenerBox> listener) {
return new Monero::RustListenerAdapter(std::move(listener));
}
void destroy_rust_listener_adapter(Monero::WalletListener* ptr) {
delete ptr;
}
}

View file

@ -54,10 +54,11 @@ pub mod ffi {
/// A pending transaction. /// A pending transaction.
type PendingTransaction; type PendingTransaction;
/// A wallet listener. /// A struct containing transaction history.
/// type TransactionHistory;
/// Can be attached to a wallet and will get notified upon specific events.
type WalletListener; /// A struct containing a single transaction.
type TransactionInfo;
/// Get the wallet manager. /// Get the wallet manager.
fn getWalletManager() -> Result<*mut WalletManager>; fn getWalletManager() -> Result<*mut WalletManager>;
@ -100,6 +101,8 @@ pub mod ffi {
seed_offset: &CxxString, seed_offset: &CxxString,
) -> Result<*mut Wallet>; ) -> Result<*mut Wallet>;
type WalletListener;
///virtual Wallet * openWallet(const std::string &path, const std::string &password, NetworkType nettype, uint64_t kdf_rounds = 1, WalletListener * listener = nullptr) = 0; ///virtual Wallet * openWallet(const std::string &path, const std::string &password, NetworkType nettype, uint64_t kdf_rounds = 1, WalletListener * listener = nullptr) = 0;
unsafe fn openWallet( unsafe fn openWallet(
self: Pin<&mut WalletManager>, self: Pin<&mut WalletManager>,
@ -117,9 +120,21 @@ pub mod ffi {
store: bool, store: bool,
) -> Result<bool>; ) -> Result<bool>;
/// Store the wallet state.
fn store(self: Pin<&mut Wallet>, path: &CxxString) -> Result<bool>;
/// Check whether a wallet exists at the given path. /// Check whether a wallet exists at the given path.
fn walletExists(self: Pin<&mut WalletManager>, path: &CxxString) -> Result<bool>; fn walletExists(self: Pin<&mut WalletManager>, path: &CxxString) -> Result<bool>;
/// Verify the password for a wallet at the given path.
fn verifyWalletPassword(
self: &WalletManager,
keys_file_name: &CxxString,
password: &CxxString,
no_spend_key: bool,
kdf_rounds: u64,
) -> Result<bool>;
/// Set the address of the remote node ("daemon"). /// Set the address of the remote node ("daemon").
fn setDaemonAddress(self: Pin<&mut WalletManager>, address: &CxxString) -> Result<()>; fn setDaemonAddress(self: Pin<&mut WalletManager>, address: &CxxString) -> Result<()>;
@ -160,6 +175,9 @@ pub mod ffi {
/// Get the seed of the wallet. /// Get the seed of the wallet.
fn walletSeed(wallet: &Wallet, seed_offset: &CxxString) -> Result<UniquePtr<CxxString>>; fn walletSeed(wallet: &Wallet, seed_offset: &CxxString) -> Result<UniquePtr<CxxString>>;
/// Set the seed language of the wallet.
fn setSeedLanguage(self: Pin<&mut Wallet>, language: &CxxString) -> Result<()>;
/// Get the wallet creation height. /// Get the wallet creation height.
fn getRefreshFromBlockHeight(self: &Wallet) -> Result<u64>; fn getRefreshFromBlockHeight(self: &Wallet) -> Result<u64>;
@ -181,6 +199,9 @@ pub mod ffi {
/// Get the current blockchain height. /// Get the current blockchain height.
fn blockChainHeight(self: &Wallet) -> Result<u64>; fn blockChainHeight(self: &Wallet) -> Result<u64>;
/// Set a listener to the wallet.
unsafe fn setListener(self: Pin<&mut Wallet>, listener: *mut WalletListener) -> Result<()>;
/// Get the daemon's blockchain height. /// Get the daemon's blockchain height.
fn daemonBlockChainTargetHeight(self: &Wallet) -> Result<u64>; fn daemonBlockChainTargetHeight(self: &Wallet) -> Result<u64>;
@ -199,6 +220,17 @@ pub mod ffi {
/// Force a specific restore height. /// Force a specific restore height.
fn setRefreshFromBlockHeight(self: Pin<&mut Wallet>, height: u64) -> Result<()>; fn setRefreshFromBlockHeight(self: Pin<&mut Wallet>, height: u64) -> Result<()>;
fn getBlockchainHeightByDate(self: &Wallet, year: u16, month: u8, day: u8) -> Result<u64>;
/// Rescan the blockchain asynchronously.
fn rescanBlockchainAsync(self: Pin<&mut Wallet>);
/// Pause the background refresh.
fn pauseRefresh(self: Pin<&mut Wallet>);
/// Stop the background refresh once (doesn't stop background refresh thread).
fn stop(self: Pin<&mut Wallet>);
/// Set whether to allow mismatched daemon versions. /// Set whether to allow mismatched daemon versions.
fn setAllowMismatchedDaemonVersion( fn setAllowMismatchedDaemonVersion(
self: Pin<&mut Wallet>, self: Pin<&mut Wallet>,
@ -255,6 +287,12 @@ pub mod ffi {
tx: &PendingTransaction, tx: &PendingTransaction,
) -> Result<UniquePtr<CxxVector<CxxString>>>; ) -> Result<UniquePtr<CxxVector<CxxString>>>;
/// Get the fee of a pending transaction.
fn pendingTransactionFee(tx: &PendingTransaction) -> Result<u64>;
/// Get the amount of a pending transaction.
fn pendingTransactionAmount(tx: &PendingTransaction) -> Result<u64>;
/// Get the transaction key (r) for a given txid. /// Get the transaction key (r) for a given txid.
fn walletGetTxKey(wallet: &Wallet, txid: &CxxString) -> Result<UniquePtr<CxxString>>; fn walletGetTxKey(wallet: &Wallet, txid: &CxxString) -> Result<UniquePtr<CxxString>>;
@ -271,6 +309,36 @@ pub mod ffi {
tx: *mut PendingTransaction, tx: *mut PendingTransaction,
) -> Result<()>; ) -> Result<()>;
/// Get the transaction history.
fn history(self: Pin<&mut Wallet>) -> Result<*mut TransactionHistory>;
/// Get the transaction history count.
fn count(self: &TransactionHistory) -> i32;
/// Get a transaction from the history by index.
fn transaction(self: &TransactionHistory, index: i32) -> *mut TransactionInfo;
/// Refresh the transaction history so it contains the latest transactions.
fn refresh(self: Pin<&mut TransactionHistory>) -> Result<()>;
/// Get the amount of the transaction.
fn amount(self: &TransactionInfo) -> u64;
/// Get the fee of the transaction.
fn fee(self: &TransactionInfo) -> u64;
/// Get the confirmations of the transaction.
fn confirmations(self: &TransactionInfo) -> u64;
/// Get the direction of the transaction.
fn direction(self: &TransactionInfo) -> i32;
/// Get the hash of the transaction.
fn transactionInfoHash(tx_info: &TransactionInfo) -> UniquePtr<CxxString>;
/// Get the timestamp of the transaction.
fn transactionInfoTimestamp(tx_info: &TransactionInfo) -> u64;
/// Sign a message with the wallet's private key. /// Sign a message with the wallet's private key.
fn signMessage( fn signMessage(
wallet: Pin<&mut Wallet>, wallet: Pin<&mut Wallet>,
@ -334,6 +402,214 @@ pub mod log {
} }
} }
/// Wallet listener bridge using cxx's virtual table approach
#[cxx::bridge(namespace = "wallet_listener")]
pub mod wallet_listener {
extern "Rust" {
// Opaque Rust type owned by C++
type WalletListenerBox;
// Callback methods invoked from C++
fn money_spent(listener: &mut WalletListenerBox, txid: &CxxString, amount: u64);
fn money_received(listener: &mut WalletListenerBox, txid: &CxxString, amount: u64);
fn unconfirmed_money_received(
listener: &mut WalletListenerBox,
txid: &CxxString,
amount: u64,
);
fn new_block(listener: &mut WalletListenerBox, height: u64);
fn updated(listener: &mut WalletListenerBox);
fn refreshed(listener: &mut WalletListenerBox);
fn on_reorg(
listener: &mut WalletListenerBox,
height: u64,
blocks_detached: u64,
transfers_detached: usize,
);
fn pool_tx_removed(listener: &mut WalletListenerBox, txid: &CxxString);
}
unsafe extern "C++" {
include!("wallet/api/wallet2_api.h");
include!("bridge.h");
// The C++ WalletListener type lives in the Monero namespace.
#[namespace = "Monero"]
#[rust_name = "MoneroWalletListener"]
type WalletListener;
// Functions implemented in bridge.h that create / destroy the adapter.
#[namespace = "wallet_listener"]
fn create_rust_listener_adapter(
listener: Box<WalletListenerBox>,
) -> *mut MoneroWalletListener;
#[namespace = "wallet_listener"]
unsafe fn destroy_rust_listener_adapter(ptr: *mut MoneroWalletListener);
}
}
// Callback functions called from C++ - these bridge the C++ callbacks to Rust trait methods
pub fn money_spent(listener: &mut WalletListenerBox, txid: &CxxString, amount: u64) {
listener.on_money_spent(&txid.to_string(), amount);
}
pub fn money_received(listener: &mut WalletListenerBox, txid: &CxxString, amount: u64) {
listener.on_money_received(&txid.to_string(), amount);
}
pub fn unconfirmed_money_received(listener: &mut WalletListenerBox, txid: &CxxString, amount: u64) {
listener.on_unconfirmed_money_received(&txid.to_string(), amount);
}
pub fn new_block(listener: &mut WalletListenerBox, height: u64) {
listener.on_new_block(height);
}
pub fn updated(listener: &mut WalletListenerBox) {
listener.on_updated();
}
pub fn refreshed(listener: &mut WalletListenerBox) {
listener.on_refreshed();
}
pub fn on_reorg(
listener: &mut WalletListenerBox,
height: u64,
blocks_detached: u64,
transfers_detached: usize,
) {
listener.on_reorg(height, blocks_detached, transfers_detached);
}
pub fn pool_tx_removed(listener: &mut WalletListenerBox, txid: &CxxString) {
listener.on_pool_tx_removed(&txid.to_string());
}
/// Trait for wallet event listeners - allows custom callback implementations
pub trait WalletEventListener: Send + Sync {
fn on_money_spent(&self, txid: &str, amount: u64);
fn on_money_received(&self, txid: &str, amount: u64);
fn on_unconfirmed_money_received(&self, txid: &str, amount: u64);
fn on_new_block(&self, height: u64);
fn on_updated(&self);
fn on_refreshed(&self);
fn on_reorg(&self, height: u64, blocks_detached: u64, transfers_detached: usize);
fn on_pool_tx_removed(&self, txid: &str);
}
/// A wrapper around Box<dyn WalletEventListener> because CXX doesn't support trait objects (yet).
pub struct WalletListenerBox {
inner: Box<dyn WalletEventListener>,
}
impl WalletListenerBox {
/// Create a new wrapper around any WalletEventListener implementation
pub fn new(listener: Box<dyn WalletEventListener>) -> Self {
WalletListenerBox { inner: listener }
}
/// Create a new boxed wrapper around any WalletEventListener implementation
pub fn new_boxed(listener: Box<dyn WalletEventListener>) -> Box<Self> {
Box::new(Self::new(listener))
}
}
impl WalletEventListener for WalletListenerBox {
fn on_money_spent(&self, txid: &str, amount: u64) {
self.inner.on_money_spent(txid, amount);
}
fn on_money_received(&self, txid: &str, amount: u64) {
self.inner.on_money_received(txid, amount);
}
fn on_unconfirmed_money_received(&self, txid: &str, amount: u64) {
self.inner.on_unconfirmed_money_received(txid, amount);
}
fn on_new_block(&self, height: u64) {
self.inner.on_new_block(height);
}
fn on_updated(&self) {
self.inner.on_updated();
}
fn on_refreshed(&self) {
self.inner.on_refreshed();
}
fn on_reorg(&self, height: u64, blocks_detached: u64, transfers_detached: usize) {
self.inner
.on_reorg(height, blocks_detached, transfers_detached);
}
fn on_pool_tx_removed(&self, txid: &str) {
self.inner.on_pool_tx_removed(txid);
}
}
/// Listener implementation that logs all wallet events using tracing with filename context.
pub struct TraceListener {
/// The wallet filename for logging context
pub filename: String,
}
impl TraceListener {
/// Creates a new TraceListener with a filename for logging context.
pub fn new(filename: String) -> Self {
TraceListener { filename }
}
}
impl WalletEventListener for TraceListener {
fn on_money_spent(&self, txid: &str, amount: u64) {
tracing::info!(
wallet = self.filename,
"Money spent: {} XMR in transaction {}",
amount as f64 / 1e12,
txid
);
}
fn on_money_received(&self, txid: &str, amount: u64) {
tracing::info!(
wallet = self.filename,
"Money received: {} XMR in transaction {}",
amount as f64 / 1e12,
txid
);
}
fn on_unconfirmed_money_received(&self, txid: &str, amount: u64) {
tracing::info!(
wallet = self.filename,
"Unconfirmed money received: {} XMR in transaction {}",
amount as f64 / 1e12,
txid
);
}
fn on_new_block(&self, _height: u64) {}
fn on_updated(&self) {}
fn on_refreshed(&self) {}
fn on_reorg(&self, height: u64, blocks_detached: u64, transfers_detached: usize) {
tracing::warn!(
wallet = self.filename,
"Blockchain reorganization at height {}: {} blocks detached, {} transfers detached",
height,
blocks_detached,
transfers_detached
);
}
fn on_pool_tx_removed(&self, _txid: &str) {}
}
/// This is the actual rust function that forwards the c++ log messages to tracing. /// This is the actual rust function that forwards the c++ log messages to tracing.
/// It is called every time C++ issues a log message. /// It is called every time C++ issues a log message.
/// ///

View file

@ -0,0 +1,86 @@
use std::path::PathBuf;
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use tracing::info;
#[derive(Clone)]
pub struct Database {
pub pool: SqlitePool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecentWallet {
pub wallet_path: String,
pub last_opened_at: DateTime<Utc>,
}
impl Database {
pub async fn new(data_dir: PathBuf) -> Result<Self> {
if !data_dir.exists() {
std::fs::create_dir_all(&data_dir)?;
info!("Created wallet database directory: {}", data_dir.display());
}
let db_path = data_dir.join("recent_wallets.db");
let database_url = format!("sqlite:{}?mode=rwc", db_path.display());
let pool = SqlitePool::connect(&database_url).await?;
let db = Self { pool };
db.migrate().await?;
Ok(db)
}
async fn migrate(&self) -> Result<()> {
sqlx::migrate!("./migrations").run(&self.pool).await?;
info!("Recent wallets database migration completed");
Ok(())
}
/// Record that a wallet was accessed
pub async fn record_wallet_access(&self, wallet_path: &str) -> Result<()> {
let now = Utc::now().to_rfc3339();
sqlx::query!(
r#"
INSERT INTO recent_wallets (wallet_path, last_opened_at)
VALUES (?, ?)
ON CONFLICT(wallet_path) DO UPDATE SET last_opened_at = excluded.last_opened_at
"#,
wallet_path,
now
)
.execute(&self.pool)
.await?;
Ok(())
}
/// Get recently opened wallets, most recent first
pub async fn get_recent_wallets(&self, limit: i64) -> Result<Vec<RecentWallet>> {
let rows = sqlx::query!(
r#"
SELECT wallet_path, last_opened_at
FROM recent_wallets
ORDER BY last_opened_at DESC
LIMIT ?
"#,
limit
)
.fetch_all(&self.pool)
.await?;
let wallets: Vec<RecentWallet> = rows
.into_iter()
.map(|row| RecentWallet {
wallet_path: row.wallet_path,
last_opened_at: row.last_opened_at.parse().unwrap_or_else(|_| Utc::now()),
})
.collect();
Ok(wallets)
}
}

File diff suppressed because it is too large Load diff

View file

@ -33,50 +33,68 @@ async fn test_sign_message() {
// Test message to sign // Test message to sign
let test_message = "Hello, World! This is a test message for signing."; let test_message = "Hello, World! This is a test message for signing.";
tracing::info!("Testing message signing with spend key (default address)"); tracing::info!("Testing message signing with spend key (default address)");
let signature_spend = wallet let signature_spend = wallet
.sign_message(test_message, None, false) .sign_message(test_message, None, false)
.await .await
.expect("Failed to sign message with spend key"); .expect("Failed to sign message with spend key");
tracing::info!("Signature with spend key: {}", signature_spend); tracing::info!("Signature with spend key: {}", signature_spend);
assert!(!signature_spend.is_empty(), "Signature should not be empty"); assert!(!signature_spend.is_empty(), "Signature should not be empty");
assert!(signature_spend.len() > 10, "Signature should be reasonably long"); assert!(
signature_spend.len() > 10,
"Signature should be reasonably long"
);
tracing::info!("Testing message signing with view key (default address)"); tracing::info!("Testing message signing with view key (default address)");
let signature_view = wallet let signature_view = wallet
.sign_message(test_message, None, true) .sign_message(test_message, None, true)
.await .await
.expect("Failed to sign message with view key"); .expect("Failed to sign message with view key");
tracing::info!("Signature with view key: {}", signature_view); tracing::info!("Signature with view key: {}", signature_view);
assert!(!signature_view.is_empty(), "Signature should not be empty"); assert!(!signature_view.is_empty(), "Signature should not be empty");
assert!(signature_view.len() > 10, "Signature should be reasonably long"); assert!(
signature_view.len() > 10,
"Signature should be reasonably long"
);
// Signatures should be different when using different keys // Signatures should be different when using different keys
assert_ne!(signature_spend, signature_view, "Spend key and view key signatures should be different"); assert_ne!(
signature_spend, signature_view,
"Spend key and view key signatures should be different"
);
tracing::info!("Testing message signing with spend key (explicit address)"); tracing::info!("Testing message signing with spend key (explicit address)");
let signature_explicit = wallet let signature_explicit = wallet
.sign_message(test_message, Some(&main_address.to_string()), false) .sign_message(test_message, Some(&main_address.to_string()), false)
.await .await
.expect("Failed to sign message with explicit address"); .expect("Failed to sign message with explicit address");
tracing::info!("Signature with explicit address: {}", signature_explicit); tracing::info!("Signature with explicit address: {}", signature_explicit);
assert!(!signature_explicit.is_empty(), "Signature should not be empty"); assert!(
!signature_explicit.is_empty(),
"Signature should not be empty"
);
// When using the same key and same address (main address), signatures should be the same // When using the same key and same address (main address), signatures should be the same
assert_eq!(signature_spend, signature_explicit, "Signatures should be the same when using same key and address"); assert_eq!(
signature_spend, signature_explicit,
"Signatures should be the same when using same key and address"
);
tracing::info!("Testing empty message signing"); tracing::info!("Testing empty message signing");
let signature_empty = wallet let signature_empty = wallet
.sign_message("", None, false) .sign_message("", None, false)
.await .await
.expect("Failed to sign empty message"); .expect("Failed to sign empty message");
tracing::info!("Signature for empty message: {}", signature_empty); tracing::info!("Signature for empty message: {}", signature_empty);
assert!(!signature_empty.is_empty(), "Signature should not be empty even for empty message"); assert!(
!signature_empty.is_empty(),
"Signature should not be empty even for empty message"
);
tracing::info!("All message signing tests passed!"); tracing::info!("All message signing tests passed!");
} }

File diff suppressed because it is too large Load diff

View file

@ -4,9 +4,9 @@
"version": "0.7.0", "version": "0.7.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"check-bindings": "typeshare --lang=typescript --output-file __temp_bindings.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src && dprint fmt __temp_bindings.ts && diff -wbB __temp_bindings.ts ./src/models/tauriModel.ts && rm __temp_bindings.ts", "check-bindings": "typeshare --lang=typescript --output-file __temp_bindings.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src ../monero-sys/src && dprint fmt __temp_bindings.ts && diff -wbB __temp_bindings.ts ./src/models/tauriModel.ts && rm __temp_bindings.ts",
"gen-bindings-verbose": "RUST_LOG=debug RUST_BACKTRACE=1 typeshare --lang=typescript --output-file ./src/models/tauriModel.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src && dprint fmt ./src/models/tauriModel.ts", "gen-bindings-verbose": "RUST_LOG=debug RUST_BACKTRACE=1 typeshare --lang=typescript --output-file ./src/models/tauriModel.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src ../monero-sys/src && dprint fmt ./src/models/tauriModel.ts",
"gen-bindings": "typeshare --lang=typescript --output-file ./src/models/tauriModel.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src && dprint fmt ./src/models/tauriModel.ts", "gen-bindings": "typeshare --lang=typescript --output-file ./src/models/tauriModel.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src ../monero-sys/src && dprint fmt ./src/models/tauriModel.ts",
"test": "vitest", "test": "vitest",
"test:ui": "vitest --ui", "test:ui": "vitest --ui",
"dev": "vite", "dev": "vite",
@ -22,10 +22,12 @@
"@mui/icons-material": "^7.1.1", "@mui/icons-material": "^7.1.1",
"@mui/lab": "^7.0.0-beta.13", "@mui/lab": "^7.0.0-beta.13",
"@mui/material": "^7.1.1", "@mui/material": "^7.1.1",
"@mui/x-date-pickers": "^8.8.0",
"@reduxjs/toolkit": "^2.3.0", "@reduxjs/toolkit": "^2.3.0",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-cli": "^2.0.0", "@tauri-apps/plugin-cli": "^2.0.0",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0", "@tauri-apps/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-opener": "^2.0.0", "@tauri-apps/plugin-opener": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0", "@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0", "@tauri-apps/plugin-shell": "^2.0.0",
@ -33,6 +35,7 @@
"@tauri-apps/plugin-updater": "2.7.1", "@tauri-apps/plugin-updater": "2.7.1",
"@types/react-redux": "^7.1.34", "@types/react-redux": "^7.1.34",
"boring-avatars": "^1.11.2", "boring-avatars": "^1.11.2",
"dayjs": "^1.11.13",
"humanize-duration": "^3.32.1", "humanize-duration": "^3.32.1",
"jdenticon": "^3.3.0", "jdenticon": "^3.3.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",

View file

@ -8,6 +8,7 @@ import {
SelectMakerDetails, SelectMakerDetails,
TauriBackgroundProgress, TauriBackgroundProgress,
TauriSwapProgressEvent, TauriSwapProgressEvent,
SendMoneroDetails,
} from "./tauriModel"; } from "./tauriModel";
export type TauriSwapProgressEventType = TauriSwapProgressEvent["type"]; export type TauriSwapProgressEventType = TauriSwapProgressEvent["type"];
@ -310,10 +311,13 @@ export type PendingSelectMakerApprovalRequest = PendingApprovalRequest & {
request: { type: "SelectMaker"; content: SelectMakerDetails }; request: { type: "SelectMaker"; content: SelectMakerDetails };
}; };
export interface SortableQuoteWithAddress extends QuoteWithAddress { export type PendingSendMoneroApprovalRequest = PendingApprovalRequest & {
expiration_ts?: number; request: { type: "SendMonero"; content: SendMoneroDetails };
request_id?: string; };
}
export type PendingPasswordApprovalRequest = PendingApprovalRequest & {
request: { type: "PasswordRequest"; content: { wallet_path: string } };
};
export function isPendingSelectMakerApprovalEvent( export function isPendingSelectMakerApprovalEvent(
event: ApprovalRequest, event: ApprovalRequest,
@ -327,6 +331,30 @@ export function isPendingSelectMakerApprovalEvent(
return event.request.type === "SelectMaker"; return event.request.type === "SelectMaker";
} }
export function isPendingSendMoneroApprovalEvent(
event: ApprovalRequest,
): event is PendingSendMoneroApprovalRequest {
// Check if the request is pending
if (event.request_status.state !== "Pending") {
return false;
}
// Check if the request is a SendMonero request
return event.request.type === "SendMonero";
}
export function isPendingPasswordApprovalEvent(
event: ApprovalRequest,
): event is PendingPasswordApprovalRequest {
// Check if the request is pending
if (event.request_status.state !== "Pending") {
return false;
}
// Check if the request is a PasswordRequest request
return event.request.type === "PasswordRequest";
}
/** /**
* Checks if any funds have been locked yet based on the swap progress event * Checks if any funds have been locked yet based on the swap progress event
* Returns true for events where funds have been locked * Returns true for events where funds have been locked

View file

@ -24,9 +24,16 @@ import {
listSellersAtRendezvousPoint, listSellersAtRendezvousPoint,
refreshApprovals, refreshApprovals,
updateAllNodeStatuses, updateAllNodeStatuses,
fetchAndUpdateBackgroundItems,
fetchAndUpdateApprovalItems,
} from "./rpc"; } from "./rpc";
import { store } from "./store/storeRenderer"; import { store } from "./store/storeRenderer";
import { exhaustiveGuard } from "utils/typescriptUtils"; import { exhaustiveGuard } from "utils/typescriptUtils";
import {
setBalance,
setHistory,
setSyncProgress,
} from "store/features/walletSlice";
const TAURI_UNIFIED_EVENT_CHANNEL_NAME = "tauri-unified-event"; const TAURI_UNIFIED_EVENT_CHANNEL_NAME = "tauri-unified-event";
@ -45,7 +52,7 @@ const UPDATE_RATE_INTERVAL = 5 * 60 * 1_000;
// Fetch all conversations every 10 minutes // Fetch all conversations every 10 minutes
const FETCH_CONVERSATIONS_INTERVAL = 10 * 60 * 1_000; const FETCH_CONVERSATIONS_INTERVAL = 10 * 60 * 1_000;
// Fetch pending approvals every 10 seconds // Fetch pending approvals every 2 seconds
const FETCH_PENDING_APPROVALS_INTERVAL = 2 * 1_000; const FETCH_PENDING_APPROVALS_INTERVAL = 2 * 1_000;
function setIntervalImmediate(callback: () => void, interval: number): void { function setIntervalImmediate(callback: () => void, interval: number): void {
@ -137,6 +144,19 @@ export async function setupBackgroundTasks(): Promise<void> {
store.dispatch(poolStatusReceived(eventData)); store.dispatch(poolStatusReceived(eventData));
break; break;
case "MoneroWalletUpdate":
console.log("MoneroWalletUpdate", eventData);
if (eventData.type === "BalanceChange") {
store.dispatch(setBalance(eventData.content));
}
if (eventData.type === "HistoryUpdate") {
store.dispatch(setHistory(eventData.content));
}
if (eventData.type === "SyncProgress") {
store.dispatch(setSyncProgress(eventData.content));
}
break;
default: default:
exhaustiveGuard(channelName); exhaustiveGuard(channelName);
} }

View file

@ -20,7 +20,11 @@ import { setupBackgroundTasks } from "renderer/background";
import "@fontsource/roboto"; import "@fontsource/roboto";
import FeedbackPage from "./pages/feedback/FeedbackPage"; import FeedbackPage from "./pages/feedback/FeedbackPage";
import IntroductionModal from "./modal/introduction/IntroductionModal"; import IntroductionModal from "./modal/introduction/IntroductionModal";
import MoneroWalletPage from "./pages/monero/MoneroWalletPage";
import SeedSelectionDialog from "./modal/seed-selection/SeedSelectionDialog"; import SeedSelectionDialog from "./modal/seed-selection/SeedSelectionDialog";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import PasswordEntryDialog from "./modal/password-entry/PasswordEntryDialog";
declare module "@mui/material/styles" { declare module "@mui/material/styles" {
interface Theme { interface Theme {
@ -44,16 +48,19 @@ export default function App() {
return ( return (
<StyledEngineProvider injectFirst> <StyledEngineProvider injectFirst>
<ThemeProvider theme={currentTheme}> <ThemeProvider theme={currentTheme}>
<CssBaseline /> <LocalizationProvider dateAdapter={AdapterDayjs}>
<GlobalSnackbarProvider> <CssBaseline />
<IntroductionModal /> <GlobalSnackbarProvider>
<SeedSelectionDialog /> <IntroductionModal />
<Router> <SeedSelectionDialog />
<Navigation /> <PasswordEntryDialog />
<InnerContent /> <Router>
<UpdaterDialog /> <Navigation />
</Router> <InnerContent />
</GlobalSnackbarProvider> <UpdaterDialog />
</Router>
</GlobalSnackbarProvider>
</LocalizationProvider>
</ThemeProvider> </ThemeProvider>
</StyledEngineProvider> </StyledEngineProvider>
); );
@ -70,12 +77,13 @@ function InnerContent() {
}} }}
> >
<Routes> <Routes>
<Route path="/" element={<MoneroWalletPage />} />
<Route path="/monero-wallet" element={<MoneroWalletPage />} />
<Route path="/swap" element={<SwapPage />} /> <Route path="/swap" element={<SwapPage />} />
<Route path="/history" element={<HistoryPage />} /> <Route path="/history" element={<HistoryPage />} />
<Route path="/wallet" element={<WalletPage />} /> <Route path="/bitcoin-wallet" element={<WalletPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/feedback" element={<FeedbackPage />} /> <Route path="/feedback" element={<FeedbackPage />} />
<Route path="/" element={<SwapPage />} />
</Routes> </Routes>
</Box> </Box>
); );

View file

@ -186,18 +186,11 @@ export default function DaemonStatusAlert() {
const contextStatus = useAppSelector((s) => s.rpc.status); const contextStatus = useAppSelector((s) => s.rpc.status);
const navigate = useNavigate(); const navigate = useNavigate();
if (
contextStatus === null ||
contextStatus === TauriContextStatusEvent.NotInitialized
) {
return (
<LoadingSpinnerAlert severity="warning">
Checking for available remote nodes
</LoadingSpinnerAlert>
);
}
switch (contextStatus) { switch (contextStatus) {
case null:
return null;
case TauriContextStatusEvent.NotInitialized:
return null;
case TauriContextStatusEvent.Initializing: case TauriContextStatusEvent.Initializing:
return null; return null;
case TauriContextStatusEvent.Available: case TauriContextStatusEvent.Available:

View file

@ -16,7 +16,7 @@ export default function FundsLeftInWalletAlert() {
<Button <Button
variant="outlined" variant="outlined"
size="small" size="small"
onClick={() => navigate("/wallet")} onClick={() => navigate("/bitcoin-wallet")}
> >
View View
</Button> </Button>

View file

@ -1,3 +1,4 @@
import React from "react";
import { Box, Alert, AlertTitle } from "@mui/material"; import { Box, Alert, AlertTitle } from "@mui/material";
import { import {
BobStateName, BobStateName,

View file

@ -23,7 +23,7 @@ type MoneroAddressTextFieldProps = TextFieldProps & {
address: string; address: string;
onAddressChange: (address: string) => void; onAddressChange: (address: string) => void;
onAddressValidityChange: (valid: boolean) => void; onAddressValidityChange: (valid: boolean) => void;
helperText: string; helperText?: string;
}; };
export default function MoneroAddressTextField({ export default function MoneroAddressTextField({

View file

@ -0,0 +1,178 @@
import { useEffect, useRef, useState } from "react";
import { darken, useTheme } from "@mui/material";
interface NumberInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
fontSize?: string;
fontWeight?: number;
textAlign?: "left" | "center" | "right";
minWidth?: number;
step?: number;
largeStep?: number;
className?: string;
style?: React.CSSProperties;
}
export default function NumberInput({
value,
onChange,
placeholder = "0.00",
fontSize = "2em",
fontWeight = 600,
textAlign = "right",
minWidth = 60,
step = 0.001,
largeStep = 0.1,
className,
style,
}: NumberInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [inputWidth, setInputWidth] = useState(minWidth);
const [isFocused, setIsFocused] = useState(false);
const measureRef = useRef<HTMLSpanElement>(null);
// Calculate precision from step value
const getDecimalPrecision = (num: number): number => {
const str = num.toString();
if (str.includes(".")) {
return str.split(".")[1].length;
}
return 0;
};
const [userPrecision, setUserPrecision] = useState(() =>
getDecimalPrecision(step),
); // Track user's decimal precision
const [minPrecision, setMinPrecision] = useState(3);
const appliedPrecision =
userPrecision > minPrecision ? userPrecision : minPrecision;
const theme = useTheme();
// Initialize with placeholder if no value provided
useEffect(() => {
if (
(!value || value.trim() === "" || parseFloat(value) === 0) &&
!isFocused
) {
onChange(placeholder);
}
}, [placeholder, isFocused, value, onChange]);
// Update precision when step changes
useEffect(() => {
setUserPrecision(getDecimalPrecision(step));
setMinPrecision(getDecimalPrecision(step));
}, [step]);
// Measure text width to size input dynamically
useEffect(() => {
if (measureRef.current) {
const text = value;
measureRef.current.textContent = text;
const textWidth = measureRef.current.offsetWidth;
setInputWidth(Math.max(textWidth + 5, minWidth)); // Add padding and minimum width
}
}, [value, placeholder, minWidth]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const currentValue = parseFloat(value) || 0;
const increment = e.shiftKey ? largeStep : step;
const newValue =
e.key === "ArrowUp"
? currentValue + increment
: Math.max(0, currentValue - increment);
// Use the user's precision for formatting
onChange(newValue.toFixed(appliedPrecision));
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
// Allow empty string, numbers, and decimal points
if (inputValue === "" || /^\d*\.?\d*$/.test(inputValue)) {
onChange(inputValue);
// Track the user's decimal precision
if (inputValue.includes(".")) {
const decimalPart = inputValue.split(".")[1];
setUserPrecision(decimalPart ? decimalPart.length : 0);
} else if (inputValue && !isNaN(parseFloat(inputValue))) {
// No decimal point, so precision is 0
setUserPrecision(0);
}
}
};
const handleBlur = () => {
setIsFocused(false);
// Reset to placeholder if value is zero or empty
if (!value || value.trim() === "" || parseFloat(value) === 0) {
onChange(placeholder);
} else if (!isNaN(parseFloat(value))) {
// Format valid numbers on blur using user's precision
const formatted = parseFloat(value).toFixed(appliedPrecision);
// Remove trailing zeros if precision allows
onChange(parseFloat(formatted).toString());
}
};
const handleFocus = () => {
setIsFocused(true);
};
// Determine if we should show placeholder styling
const isShowingPlaceholder = value === placeholder && !isFocused;
const defaultStyle: React.CSSProperties = {
fontSize,
fontWeight,
textAlign,
border: "none",
outline: "none",
background: "transparent",
width: `${inputWidth}px`,
minWidth: `${minWidth}px`,
fontFamily: "inherit",
color: isShowingPlaceholder
? darken(theme.palette.text.primary, 0.5)
: theme.palette.text.primary,
padding: "4px 0",
transition: "color 0.2s ease",
...style,
};
return (
<div style={{ position: "relative", display: "inline-block" }}>
{/* Hidden span for measuring text width */}
<span
ref={measureRef}
style={{
position: "absolute",
visibility: "hidden",
fontSize,
fontWeight,
fontFamily: "inherit",
whiteSpace: "nowrap",
}}
/>
<input
ref={inputRef}
type="text"
inputMode="decimal"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onFocus={handleFocus}
className={className}
style={defaultStyle}
/>
</div>
);
}

View file

@ -0,0 +1,126 @@
import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
Button,
IconButton,
InputAdornment,
} from "@mui/material";
import { Visibility, VisibilityOff } from "@mui/icons-material";
import { useState } from "react";
import { usePendingPasswordApproval } from "store/hooks";
import { rejectApproval, resolveApproval } from "renderer/rpc";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
export default function PasswordEntryDialog() {
const pendingApprovals = usePendingPasswordApproval();
const [password, setPassword] = useState<string>("");
const [error, setError] = useState<string>("");
const [showPassword, setShowPassword] = useState<boolean>(false);
const approval = pendingApprovals[0];
const accept = async () => {
if (!approval) {
throw new Error("No approval request found for password entry");
}
try {
await resolveApproval<string>(approval.request_id, password);
setPassword("");
setError("");
} catch (err) {
setError("Invalid password. Please try again.");
throw err;
}
};
const reject = async () => {
if (!approval) {
throw new Error("No approval request found for password entry");
}
try {
await rejectApproval<string>(approval.request_id, "");
setPassword("");
setError("");
} catch (err) {
console.error("Error rejecting password request:", err);
throw err;
}
};
const handleTogglePasswordVisibility = () => {
setShowPassword(!showPassword);
};
if (!approval) {
return null;
}
return (
<Dialog
open={true}
maxWidth="sm"
fullWidth
BackdropProps={{
sx: {
backdropFilter: "blur(8px)",
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
}}
>
<DialogTitle>Enter Wallet Password</DialogTitle>
<DialogContent>
<TextField
fullWidth
type={showPassword ? "text" : "password"}
label="Password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
if (error) setError("");
}}
error={!!error}
helperText={error}
autoFocus
margin="normal"
onKeyPress={(e) => {
if (e.key === "Enter") {
accept();
}
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={handleTogglePasswordVisibility}
edge="end"
aria-label="toggle password visibility"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={reject} variant="outlined">
Change wallet
</Button>
<PromiseInvokeButton
onInvoke={accept}
variant="contained"
requiresContext={false}
>
Unlock
</PromiseInvokeButton>
</DialogActions>
</Dialog>
);
}

View file

@ -1,5 +1,4 @@
import { import {
Button,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
@ -10,17 +9,43 @@ import {
RadioGroup, RadioGroup,
TextField, TextField,
Typography, Typography,
Button,
Box,
List,
ListItem,
ListItemButton,
ListItemText,
Divider,
Card,
CardContent,
} from "@mui/material"; } from "@mui/material";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { usePendingSeedSelectionApproval } from "store/hooks"; import { usePendingSeedSelectionApproval } from "store/hooks";
import { resolveApproval, checkSeed } from "renderer/rpc"; import { resolveApproval, checkSeed } from "renderer/rpc";
import { SeedChoice } from "models/tauriModel";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import { open } from "@tauri-apps/plugin-dialog";
import AddIcon from "@mui/icons-material/Add";
import RefreshIcon from "@mui/icons-material/Refresh";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import SearchIcon from "@mui/icons-material/Search";
export default function SeedSelectionDialog() { export default function SeedSelectionDialog() {
const pendingApprovals = usePendingSeedSelectionApproval(); const pendingApprovals = usePendingSeedSelectionApproval();
const [selectedOption, setSelectedOption] = useState<string>("RandomSeed"); const [selectedOption, setSelectedOption] = useState<
SeedChoice["type"] | undefined
>("RandomSeed");
const [customSeed, setCustomSeed] = useState<string>(""); const [customSeed, setCustomSeed] = useState<string>("");
const [isSeedValid, setIsSeedValid] = useState<boolean>(false); const [isSeedValid, setIsSeedValid] = useState<boolean>(false);
const approval = pendingApprovals[0]; // Handle the first pending approval const [walletPath, setWalletPath] = useState<string>("");
const approval = pendingApprovals[0];
// Extract recent wallets from the approval request content
const recentWallets =
approval?.request?.type === "SeedSelection"
? approval.request.content.recent_wallets
: [];
useEffect(() => { useEffect(() => {
if (selectedOption === "FromSeed" && customSeed.trim()) { if (selectedOption === "FromSeed" && customSeed.trim()) {
@ -36,51 +61,193 @@ export default function SeedSelectionDialog() {
} }
}, [customSeed, selectedOption]); }, [customSeed, selectedOption]);
const handleClose = async (accept: boolean) => { // Auto-select the first recent wallet if available
if (!approval) return; useEffect(() => {
if (recentWallets.length > 0) {
if (accept) { setSelectedOption("FromWalletPath");
const seedChoice = setWalletPath(recentWallets[0]);
selectedOption === "RandomSeed"
? { type: "RandomSeed" }
: { type: "FromSeed", content: { seed: customSeed } };
await resolveApproval(approval.request_id, seedChoice);
} else {
// On reject, just close without approval
await resolveApproval(approval.request_id, { type: "RandomSeed" });
} }
}, [recentWallets.length]);
const selectWalletFile = async () => {
const selected = await open({
multiple: false,
directory: false,
});
if (selected) {
setWalletPath(selected);
}
};
const Legacy = async () => {
if (!approval)
throw new Error("No approval request found for seed selection");
await resolveApproval<SeedChoice>(approval.request_id, {
type: "Legacy",
});
};
const accept = async () => {
if (!approval)
throw new Error("No approval request found for seed selection");
const seedChoice: SeedChoice =
selectedOption === "RandomSeed"
? { type: "RandomSeed" }
: selectedOption === "FromSeed"
? { type: "FromSeed", content: { seed: customSeed } }
: { type: "FromWalletPath", content: { wallet_path: walletPath } };
await resolveApproval<SeedChoice>(approval.request_id, seedChoice);
}; };
if (!approval) { if (!approval) {
return null; return null;
} }
return ( // Disable the button if the user is restoring from a seed and the seed is invalid
<Dialog open={true} maxWidth="sm" fullWidth> // or if selecting wallet path and no path is selected
<DialogTitle>Monero Wallet</DialogTitle> const isDisabled =
<DialogContent> selectedOption === "FromSeed"
<Typography variant="body1" sx={{ mb: 2 }}> ? customSeed.trim().length === 0 || !isSeedValid
Choose what seed to use for the wallet. : selectedOption === "FromWalletPath"
</Typography> ? !walletPath
: false;
<FormControl component="fieldset"> return (
<RadioGroup <Dialog
value={selectedOption} open={true}
onChange={(e) => setSelectedOption(e.target.value)} maxWidth="sm"
fullWidth
sx={{ "& .MuiDialog-paper": { minHeight: "min(32rem, 80vh)" } }}
BackdropProps={{
sx: {
backdropFilter: "blur(8px)",
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
}}
>
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
<Box sx={{ display: "flex", flexDirection: "row", gap: 2 }}>
{/* Open existing wallet option */}
<Card
sx={{
cursor: "pointer",
border: selectedOption === "FromWalletPath" ? 2 : 1,
borderColor:
selectedOption === "FromWalletPath"
? "primary.main"
: "divider",
"&:hover": { borderColor: "primary.main" },
flex: 1,
}}
onClick={() => setSelectedOption("FromWalletPath")}
> >
<FormControlLabel <CardContent
value="RandomSeed" sx={{
control={<Radio />} display: "flex",
label="Create a new wallet" flexDirection: "column",
/> alignItems: "center",
<FormControlLabel justifyContent: "center",
value="FromSeed" gap: 1,
control={<Radio />} }}
label="Restore wallet from seed" >
/> <FolderOpenIcon sx={{ fontSize: 32, color: "text.secondary" }} />
</RadioGroup> <Typography
</FormControl> variant="caption"
color="text.secondary"
sx={{ textAlign: "center" }}
>
Open wallet file
</Typography>
</CardContent>
</Card>
{/* Create new wallet option */}
<Card
sx={{
cursor: "pointer",
border: selectedOption === "RandomSeed" ? 2 : 1,
borderColor:
selectedOption === "RandomSeed" ? "primary.main" : "divider",
"&:hover": { borderColor: "primary.main" },
flex: 1,
}}
onClick={() => setSelectedOption("RandomSeed")}
>
<CardContent
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 1,
}}
>
<AddIcon sx={{ fontSize: 32, color: "text.secondary" }} />
<Typography
variant="caption"
color="text.secondary"
sx={{ textAlign: "center" }}
>
Create new wallet
</Typography>
</CardContent>
</Card>
{/* Restore from seed option */}
<Card
sx={{
cursor: "pointer",
border: selectedOption === "FromSeed" ? 2 : 1,
borderColor:
selectedOption === "FromSeed" ? "primary.main" : "divider",
"&:hover": { borderColor: "primary.main" },
flex: 1,
}}
onClick={() => setSelectedOption("FromSeed")}
>
<CardContent
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 1,
}}
>
<RefreshIcon sx={{ fontSize: 32, color: "text.secondary" }} />
<Typography
variant="caption"
color="text.secondary"
sx={{ textAlign: "center" }}
>
Restore from seed
</Typography>
</CardContent>
</Card>
</Box>
{selectedOption === "RandomSeed" && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: "center" }}
>
A new wallet with a random seed phrase will be generated.
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ textAlign: "center" }}
>
You will have the option to back it up later.
</Typography>
</Box>
)}
{selectedOption === "FromSeed" && ( {selectedOption === "FromSeed" && (
<TextField <TextField
@ -90,7 +257,6 @@ export default function SeedSelectionDialog() {
label="Enter your seed phrase" label="Enter your seed phrase"
value={customSeed} value={customSeed}
onChange={(e) => setCustomSeed(e.target.value)} onChange={(e) => setCustomSeed(e.target.value)}
sx={{ mt: 2 }}
placeholder="Enter your Monero 25 words seed phrase..." placeholder="Enter your Monero 25 words seed phrase..."
error={!isSeedValid && customSeed.length > 0} error={!isSeedValid && customSeed.length > 0}
helperText={ helperText={
@ -102,19 +268,115 @@ export default function SeedSelectionDialog() {
} }
/> />
)} )}
{selectedOption === "FromWalletPath" && (
<Box sx={{ gap: 2, display: "flex", flexDirection: "column" }}>
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<TextField
fullWidth
label="Wallet file path"
value={walletPath || ""}
placeholder="Select a wallet file..."
InputProps={{
readOnly: true,
}}
/>
<Button
variant="outlined"
onClick={selectWalletFile}
sx={{ minWidth: "120px", height: "56px" }}
startIcon={<SearchIcon />}
>
Browse
</Button>
</Box>
{recentWallets.length > 0 && (
<Box>
<Box
sx={{
border: 1,
borderColor: "divider",
borderRadius: 1,
maxHeight: 200,
overflowY: "scroll",
"&::-webkit-scrollbar": {
display: "block !important",
width: "8px !important",
},
"&::-webkit-scrollbar-track": {
display: "block !important",
background: "rgba(255,255,255,.1) !important",
borderRadius: "4px",
},
"&::-webkit-scrollbar-thumb": {
display: "block !important",
background: "rgba(255,255,255,.6) !important",
borderRadius: "4px",
minHeight: "20px !important",
},
"&::-webkit-scrollbar-thumb:hover": {
background: "rgba(255,255,255,.8) !important",
},
"&::-webkit-scrollbar-corner": {
background: "transparent !important",
},
scrollbarWidth: "thin",
scrollbarColor: "rgba(255,255,255,.6) rgba(255,255,255,.1)",
}}
>
<List disablePadding>
{recentWallets.map((path, index) => (
<Box key={index}>
<ListItem disablePadding>
<ListItemButton
selected={walletPath === path}
onClick={() => setWalletPath(path)}
>
<ListItemText
primary={path.split("/").pop() || path}
secondary={path}
primaryTypographyProps={{
fontWeight: walletPath === path ? 600 : 400,
fontSize: "0.9rem",
}}
secondaryTypographyProps={{
fontSize: "0.75rem",
sx: {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
}}
/>
</ListItemButton>
</ListItem>
{index < recentWallets.length - 1 && <Divider />}
</Box>
))}
</List>
</Box>
</Box>
)}
</Box>
)}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions sx={{ justifyContent: "space-between" }}>
<Button <PromiseInvokeButton
onClick={() => handleClose(true)} variant="text"
variant="contained" onInvoke={Legacy}
disabled={ requiresContext={false}
selectedOption === "FromSeed" color="inherit"
? !customSeed.trim() || !isSeedValid
: false
}
> >
Confirm No wallet (Legacy)
</Button> </PromiseInvokeButton>
<PromiseInvokeButton
onInvoke={accept}
variant="contained"
disabled={isDisabled}
requiresContext={false}
>
Continue
</PromiseInvokeButton>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); );

View file

@ -1,29 +0,0 @@
import { Box, DialogTitle, Typography } from "@mui/material";
import DebugPageSwitchBadge from "./pages/DebugPageSwitchBadge";
import FeedbackSubmitBadge from "./pages/FeedbackSubmitBadge";
export default function SwapDialogTitle({
title,
debug,
setDebug,
}: {
title: string;
debug: boolean;
setDebug: (d: boolean) => void;
}) {
return (
<DialogTitle
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h6">{title}</Typography>
<Box sx={{ display: "flex", alignItems: "center", gridGap: 1 }}>
<FeedbackSubmitBadge />
<DebugPageSwitchBadge enabled={debug} setEnabled={setDebug} />
</Box>
</DialogTitle>
);
}

View file

@ -22,6 +22,7 @@ export default function DebugPage() {
}} }}
> >
<CliLogsBox <CliLogsBox
minHeight="min(20rem, 70vh)"
logs={logs} logs={logs}
label="Logs relevant to the swap (only current session)" label="Logs relevant to the swap (only current session)"
/> />

View file

@ -5,26 +5,35 @@ import SwapHorizOutlinedIcon from "@mui/icons-material/SwapHorizOutlined";
import FeedbackOutlinedIcon from "@mui/icons-material/FeedbackOutlined"; import FeedbackOutlinedIcon from "@mui/icons-material/FeedbackOutlined";
import RouteListItemIconButton from "./RouteListItemIconButton"; import RouteListItemIconButton from "./RouteListItemIconButton";
import UnfinishedSwapsBadge from "./UnfinishedSwapsCountBadge"; import UnfinishedSwapsBadge from "./UnfinishedSwapsCountBadge";
import { useTotalUnreadMessagesCount } from "store/hooks"; import { useIsSwapRunning, useTotalUnreadMessagesCount } from "store/hooks";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsIcon from "@mui/icons-material/Settings";
import AttachMoneyIcon from "@mui/icons-material/AttachMoney";
import BitcoinIcon from "../icons/BitcoinIcon";
import MoneroIcon from "../icons/MoneroIcon";
export default function NavigationHeader() { export default function NavigationHeader() {
const totalUnreadCount = useTotalUnreadMessagesCount(); const totalUnreadCount = useTotalUnreadMessagesCount();
const isSwapRunning = useIsSwapRunning();
return ( return (
<Box> <Box>
<List> <List>
<RouteListItemIconButton name="Swap" route="/swap"> <RouteListItemIconButton name="Wallet" route={["/monero-wallet", "/"]}>
<SwapHorizOutlinedIcon /> <MoneroIcon />
</RouteListItemIconButton>
<RouteListItemIconButton name="Wallet" route="/bitcoin-wallet">
<BitcoinIcon />
</RouteListItemIconButton>
<RouteListItemIconButton name="Swap" route={["/swap"]}>
<Badge invisible={!isSwapRunning} variant="dot" color="primary">
<SwapHorizOutlinedIcon />
</Badge>
</RouteListItemIconButton> </RouteListItemIconButton>
<RouteListItemIconButton name="History" route="/history"> <RouteListItemIconButton name="History" route="/history">
<UnfinishedSwapsBadge> <UnfinishedSwapsBadge>
<HistoryOutlinedIcon /> <HistoryOutlinedIcon />
</UnfinishedSwapsBadge> </UnfinishedSwapsBadge>
</RouteListItemIconButton> </RouteListItemIconButton>
<RouteListItemIconButton name="Wallet" route="/wallet">
<AccountBalanceWalletIcon />
</RouteListItemIconButton>
<RouteListItemIconButton name="Feedback" route="/feedback"> <RouteListItemIconButton name="Feedback" route="/feedback">
<Badge <Badge
badgeContent={totalUnreadCount} badgeContent={totalUnreadCount}

View file

@ -1,6 +1,6 @@
import { ListItemIcon, ListItemText } from "@mui/material"; import { ListItemIcon, ListItemText } from "@mui/material";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import ListItemButton from "@mui/material/ListItemButton"; import ListItemButton from "@mui/material/ListItemButton";
@ -10,13 +10,31 @@ export default function RouteListItemIconButton({
children, children,
}: { }: {
name: string; name: string;
route: string; route: string[] | string;
children: ReactNode; children: ReactNode;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const routeArray = Array.isArray(route) ? route : [route];
const firstRoute = routeArray[0];
const isSelected = routeArray.some((r) => location.pathname === r);
return ( return (
<ListItemButton onClick={() => navigate(route)} key={name}> <ListItemButton
onClick={() => navigate(firstRoute)}
key={name}
sx={
isSelected
? {
backgroundColor: "action.hover",
"&:hover": {
backgroundColor: "action.selected",
},
}
: undefined
}
>
<ListItemIcon>{children}</ListItemIcon> <ListItemIcon>{children}</ListItemIcon>
<ListItemText primary={name} /> <ListItemText primary={name} />
</ListItemButton> </ListItemButton>

View file

@ -1,15 +1,18 @@
import React from "react"; import React from "react";
import { Badge } from "@mui/material"; import { Badge } from "@mui/material";
import { useResumeableSwapsCountExcludingPunished } from "store/hooks"; import { useIsSwapRunning, useResumeableSwapsCountExcludingPunished } from "store/hooks";
export default function UnfinishedSwapsBadge({ export default function UnfinishedSwapsBadge({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const isSwapRunning = useIsSwapRunning();
const resumableSwapsCount = useResumeableSwapsCountExcludingPunished(); const resumableSwapsCount = useResumeableSwapsCountExcludingPunished();
if (resumableSwapsCount > 0) { const displayedResumableSwapsCount = isSwapRunning ? resumableSwapsCount - 1 : resumableSwapsCount;
if (displayedResumableSwapsCount > 0) {
return ( return (
<Badge badgeContent={resumableSwapsCount} color="primary"> <Badge badgeContent={resumableSwapsCount} color="primary">
{children} {children}

View file

@ -16,6 +16,7 @@ type Props = {
content: string; content: string;
displayCopyIcon?: boolean; displayCopyIcon?: boolean;
enableQrCode?: boolean; enableQrCode?: boolean;
light?: boolean;
}; };
function QRCodeModal({ open, onClose, content }: ModalProps) { function QRCodeModal({ open, onClose, content }: ModalProps) {
@ -57,6 +58,7 @@ export default function ActionableMonospaceTextBox({
content, content,
displayCopyIcon = true, displayCopyIcon = true,
enableQrCode = true, enableQrCode = true,
light = false,
}: Props) { }: Props) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [qrCodeOpen, setQrCodeOpen] = useState(false); const [qrCodeOpen, setQrCodeOpen] = useState(false);
@ -88,7 +90,7 @@ export default function ActionableMonospaceTextBox({
}} }}
> >
<Box sx={{ flexGrow: 1 }} onClick={handleCopy}> <Box sx={{ flexGrow: 1 }} onClick={handleCopy}>
<MonospaceTextBox> <MonospaceTextBox light={light}>
{content} {content}
{displayCopyIcon && ( {displayCopyIcon && (
<IconButton <IconButton

View file

@ -2,16 +2,18 @@ import { Box, Typography } from "@mui/material";
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
light?: boolean;
}; };
export default function MonospaceTextBox({ children }: Props) { export default function MonospaceTextBox({ children, light = false }: Props) {
return ( return (
<Box <Box
sx={(theme) => ({ sx={(theme) => ({
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
backgroundColor: theme.palette.grey[900], backgroundColor: light ? "transparent" : theme.palette.grey[900],
borderRadius: 2, borderRadius: 2,
border: light ? `1px solid ${theme.palette.grey[800]}` : "none",
padding: theme.spacing(1), padding: theme.spacing(1),
})} })}
> >

View file

@ -63,11 +63,13 @@ export default function CliLogsBox({
logs, logs,
topRightButton = null, topRightButton = null,
autoScroll = false, autoScroll = false,
minHeight,
}: { }: {
label: string; label: string;
logs: (CliLog | string)[]; logs: (CliLog | string)[];
topRightButton?: ReactNode; topRightButton?: ReactNode;
autoScroll?: boolean; autoScroll?: boolean;
minHeight?: string;
}) { }) {
const [searchQuery, setSearchQuery] = useState<string>(""); const [searchQuery, setSearchQuery] = useState<string>("");
@ -82,6 +84,7 @@ export default function CliLogsBox({
return ( return (
<ScrollablePaperTextBox <ScrollablePaperTextBox
minHeight={minHeight}
title={label} title={label}
copyValue={logsToRawString(logs)} copyValue={logsToRawString(logs)}
searchQuery={searchQuery} searchQuery={searchQuery}

View file

@ -81,7 +81,7 @@ export default function ScrollablePaperTextBox({
gap: "0.5rem", gap: "0.5rem",
}} }}
> >
<VList ref={virtuaEl} style={{ height: MIN_HEIGHT, width: "100%" }}> <VList ref={virtuaEl} style={{ height: "100vh", width: "100%" }}>
{rows} {rows}
</VList> </VList>
</Box> </Box>

View file

@ -12,7 +12,7 @@ export default function TruncatedText({
let finalChildren = children ?? ""; let finalChildren = children ?? "";
const truncatedText = const truncatedText =
finalChildren.length > limit finalChildren.length > limit
? truncateMiddle ? truncateMiddle
? finalChildren.slice(0, Math.floor(limit / 2)) + ? finalChildren.slice(0, Math.floor(limit / 2)) +
ellipsis + ellipsis +

View file

@ -32,16 +32,43 @@ export function AmountWithUnit({
return ( return (
<Tooltip arrow title={title}> <Tooltip arrow title={title}>
<span> <span>
{amount != null {amount != null ? amount.toFixed(fixedPrecision) : "?"} {unit}
? Number.parseFloat(amount.toFixed(fixedPrecision))
: "?"}{" "}
{unit}
{parenthesisText != null ? ` (${parenthesisText})` : null} {parenthesisText != null ? ` (${parenthesisText})` : null}
</span> </span>
</Tooltip> </Tooltip>
); );
} }
export function FiatPiconeroAmount({
amount,
fixedPrecision = 2,
}: {
amount: Amount;
fixedPrecision?: number;
}) {
const xmrPrice = useAppSelector((state) => state.rates.xmrPrice);
const [fetchFiatPrices, fiatCurrency] = useSettings((settings) => [
settings.fetchFiatPrices,
settings.fiatCurrency,
]);
if (
!fetchFiatPrices ||
fiatCurrency == null ||
amount == null ||
xmrPrice == null
) {
return null;
}
return (
<span>
{(piconerosToXmr(amount) * xmrPrice).toFixed(fixedPrecision)}{" "}
{fiatCurrency}
</span>
);
}
AmountWithUnit.defaultProps = { AmountWithUnit.defaultProps = {
exchangeRate: null, exchangeRate: null,
}; };
@ -59,14 +86,20 @@ export function BitcoinAmount({ amount }: { amount: Amount }) {
); );
} }
export function MoneroAmount({ amount }: { amount: Amount }) { export function MoneroAmount({
amount,
fixedPrecision = 4,
}: {
amount: Amount;
fixedPrecision?: number;
}) {
const xmrRate = useAppSelector((state) => state.rates.xmrPrice); const xmrRate = useAppSelector((state) => state.rates.xmrPrice);
return ( return (
<AmountWithUnit <AmountWithUnit
amount={amount} amount={amount}
unit="XMR" unit="XMR"
fixedPrecision={4} fixedPrecision={fixedPrecision}
exchangeRate={xmrRate} exchangeRate={xmrRate}
/> />
); );
@ -128,8 +161,17 @@ export function SatsAmount({ amount }: { amount: Amount }) {
return <BitcoinAmount amount={btcAmount} />; return <BitcoinAmount amount={btcAmount} />;
} }
export function PiconeroAmount({ amount }: { amount: Amount }) { export function PiconeroAmount({
amount,
fixedPrecision = 8,
}: {
amount: Amount;
fixedPrecision?: number;
}) {
return ( return (
<MoneroAmount amount={amount == null ? null : piconerosToXmr(amount)} /> <MoneroAmount
amount={amount == null ? null : piconerosToXmr(amount)}
fixedPrecision={fixedPrecision}
/>
); );
} }

View file

@ -5,7 +5,6 @@ import HistoryTable from "./table/HistoryTable";
export default function HistoryPage() { export default function HistoryPage() {
return ( return (
<> <>
<Typography variant="h3">History</Typography>
<SwapTxLockAlertsBox /> <SwapTxLockAlertsBox />
<HistoryTable /> <HistoryTable />
</> </>

View file

@ -7,6 +7,8 @@ import {
TableContainer, TableContainer,
TableHead, TableHead,
TableRow, TableRow,
Typography,
Skeleton,
} from "@mui/material"; } from "@mui/material";
import { useSwapInfosSortedByDate } from "../../../../../store/hooks"; import { useSwapInfosSortedByDate } from "../../../../../store/hooks";
import HistoryRow from "./HistoryRow"; import HistoryRow from "./HistoryRow";
@ -23,19 +25,75 @@ export default function HistoryTable() {
> >
<TableContainer component={Paper}> <TableContainer component={Paper}>
<Table> <Table>
<TableHead> {swapSortedByDate.length > 0 && (
<TableRow> <TableHead>
<TableCell /> <TableRow>
<TableCell>ID</TableCell> <TableCell />
<TableCell>Amount</TableCell> <TableCell>ID</TableCell>
<TableCell>State</TableCell> <TableCell>Amount</TableCell>
<TableCell /> <TableCell>State</TableCell>
</TableRow> <TableCell />
</TableHead> </TableRow>
</TableHead>
)}
<TableBody> <TableBody>
{swapSortedByDate.map((swap) => ( {swapSortedByDate.length === 0 ? (
<HistoryRow {...swap} key={swap.swap_id} /> <>
))} <TableRow>
<TableCell colSpan={5} sx={{ textAlign: "center", py: 4 }}>
<Typography
variant="h6"
color="text.secondary"
gutterBottom
>
Nothing to see here
</Typography>
<Typography variant="body2" color="text.secondary">
You haven't made any swaps yet
</Typography>
</TableCell>
</TableRow>
{/* Skeleton rows for visual loading effect */}
{Array.from({ length: 3 }).map((_, index) => (
<TableRow key={index}>
<TableCell>
<Skeleton
animation={false}
variant="circular"
width={24}
height={24}
/>
</TableCell>
<TableCell>
<Skeleton animation={false} variant="text" width="80%" />
</TableCell>
<TableCell>
<Skeleton animation={false} variant="text" width="60%" />
</TableCell>
<TableCell>
<Skeleton
animation={false}
variant="rectangular"
width={80}
height={24}
/>
</TableCell>
<TableCell>
<Skeleton
animation={false}
variant="circular"
width={24}
height={24}
/>
</TableCell>
</TableRow>
))}
</>
) : (
swapSortedByDate.map((swap) => (
<HistoryRow {...swap} key={swap.swap_id} />
))
)}
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>

View file

@ -43,7 +43,11 @@ export default function SwapLogFileOpenButton({
<Dialog open onClose={() => setLogs(null)} fullWidth maxWidth="lg"> <Dialog open onClose={() => setLogs(null)} fullWidth maxWidth="lg">
<DialogTitle>Logs of swap {swapId}</DialogTitle> <DialogTitle>Logs of swap {swapId}</DialogTitle>
<DialogContent> <DialogContent>
<CliLogsBox logs={logs} label="Logs relevant to the swap" /> <CliLogsBox
minHeight="min(20rem, 70vh)"
logs={logs}
label="Logs relevant to the swap"
/>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button <Button

View file

@ -0,0 +1,49 @@
import { useEffect } from "react";
import { Box } from "@mui/material";
import { useAppSelector } from "store/hooks";
import { initializeMoneroWallet } from "renderer/rpc";
import {
WalletOverview,
TransactionHistory,
WalletActionButtons,
} from "./components";
import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox";
import WalletPageLoadingState from "./components/WalletPageLoadingState";
// Main MoneroWalletPage component
export default function MoneroWalletPage() {
const { mainAddress, balance, syncProgress, history } = useAppSelector(
(state) => state.wallet.state,
);
useEffect(() => {
initializeMoneroWallet();
}, []);
const isLoading = balance === null;
if (isLoading) {
return <WalletPageLoadingState />;
}
return (
<Box
sx={{
maxWidth: 800,
mx: "auto",
display: "flex",
flexDirection: "column",
gap: 2,
pb: 2,
}}
>
<WalletOverview balance={balance} syncProgress={syncProgress} />
<ActionableMonospaceTextBox
content={mainAddress}
displayCopyIcon={true}
/>
<WalletActionButtons balance={balance} />
<TransactionHistory history={history} />
</Box>
);
}

View file

@ -0,0 +1,55 @@
import { Dialog } from "@mui/material";
import SendTransactionContent from "./components/SendTransactionContent";
import SendApprovalContent from "./components/SendApprovalContent";
import { useState } from "react";
import SendSuccessContent from "./components/SendSuccessContent";
import { usePendingSendMoneroApproval } from "store/hooks";
import { SendMoneroResponse } from "models/tauriModel";
interface SendTransactionModalProps {
open: boolean;
onClose: () => void;
balance: {
unlocked_balance: string;
};
}
export default function SendTransactionModal({
balance,
open,
onClose,
}: SendTransactionModalProps) {
const pendingApprovals = usePendingSendMoneroApproval();
const hasPendingApproval = pendingApprovals.length > 0;
const [successResponse, setSuccessResponse] = useState<SendMoneroResponse | null>(null);
const showSuccess = successResponse !== null;
const handleClose = () => {
onClose();
setSuccessResponse(null);
};
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="sm"
fullWidth={!showSuccess}
PaperProps={{
sx: { borderRadius: 2 },
}}
>
{!showSuccess && !hasPendingApproval && (
<SendTransactionContent balance={balance} onClose={onClose} onSuccess={setSuccessResponse} />
)}
{!showSuccess && hasPendingApproval && (
<SendApprovalContent onClose={onClose} />
)}
{showSuccess && (
<SendSuccessContent onClose={onClose} successDetails={successResponse} />
)}
</Dialog>
);
}

View file

@ -0,0 +1,142 @@
import {
Accordion,
AccordionDetails,
AccordionSummary,
Button,
Dialog,
DialogActions,
DialogContent,
TextField,
Typography,
Radio,
} from "@mui/material";
import { DialogTitle } from "@mui/material";
import { useState, useEffect } from "react";
import { getRestoreHeight, setMoneroRestoreHeight } from "renderer/rpc";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { Dayjs } from "dayjs";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
enum RestoreOption {
BlockHeight = "blockHeight",
RestoreDate = "restoreDate",
}
export default function SetRestoreHeightModal({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const [restoreOption, setRestoreOption] = useState(RestoreOption.BlockHeight);
const [restoreHeight, setRestoreHeight] = useState<number | "">("");
const [restoreDate, setRestoreDate] = useState<Dayjs | null>(null);
const [isPending, setIsPending] = useState(false);
const [currentRestoreHeight, setCurrentRestoreHeight] =
useState<string>("Loading...");
const handleRestoreHeight = async () => {
if (restoreOption === RestoreOption.BlockHeight) {
if (typeof restoreHeight === "number") {
await setMoneroRestoreHeight(restoreHeight);
}
} else if (restoreOption === RestoreOption.RestoreDate) {
if (restoreDate) {
await setMoneroRestoreHeight(restoreDate.toDate());
}
}
};
useEffect(() => {
const fetchCurrentRestoreHeight = async () => {
try {
const response = await getRestoreHeight();
setCurrentRestoreHeight(response.height.toString());
setRestoreHeight(response.height); // Set the input field to current height
} catch (error) {
console.error("Failed to fetch restore height:", error);
setCurrentRestoreHeight("Error");
}
};
if (open) {
fetchCurrentRestoreHeight();
}
}, [open, isPending]);
const accordionStyle = {
"& .MuiAccordionSummary-content": {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1,
},
"&::before": {
opacity: "1 !important",
},
};
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Set Restore Height</DialogTitle>
<DialogContent sx={{ minWidth: "500px", minHeight: "300px" }}>
<Accordion
elevation={0}
expanded={restoreOption === RestoreOption.BlockHeight}
onChange={() => setRestoreOption(RestoreOption.BlockHeight)}
disableGutters
sx={accordionStyle}
>
<AccordionSummary>
<Typography>Restore by block height</Typography>
<Radio checked={restoreOption === RestoreOption.BlockHeight} />
</AccordionSummary>
<AccordionDetails>
<TextField
label="Restore Height"
type="number"
value={restoreHeight}
onChange={(e) => {
const value = e.target.value;
setRestoreHeight(value === "" ? "" : Number(value));
}}
/>
</AccordionDetails>
</Accordion>
<Accordion
elevation={0}
expanded={restoreOption === RestoreOption.RestoreDate}
onChange={() => setRestoreOption(RestoreOption.RestoreDate)}
disableGutters
sx={accordionStyle}
>
<AccordionSummary>
<Typography>Restore by date</Typography>
<Radio checked={restoreOption === RestoreOption.RestoreDate} />
</AccordionSummary>
<AccordionDetails>
<DatePicker
label="Restore Date"
value={restoreDate}
disableFuture
onChange={(date) => setRestoreDate(date)}
/>
</AccordionDetails>
</Accordion>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<PromiseInvokeButton
onInvoke={handleRestoreHeight}
onSuccess={onClose}
displayErrorSnackbar={true}
onPendingChange={setIsPending}
>
Confirm
</PromiseInvokeButton>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,226 @@
import { Box, Button, Card, Grow, Typography } from "@mui/material";
import NumberInput from "renderer/components/inputs/NumberInput";
import SwapVertIcon from "@mui/icons-material/SwapVert";
import { useTheme } from "@mui/material/styles";
import { piconerosToXmr } from "../../../../../utils/conversionUtils";
import { MoneroAmount } from "renderer/components/other/Units";
interface SendAmountInputProps {
balance: {
unlocked_balance: string;
};
amount: string;
onAmountChange: (amount: string) => void;
onMaxClicked?: () => void;
onMaxToggled?: () => void;
currency: string;
onCurrencyChange: (currency: string) => void;
fiatCurrency: string;
xmrPrice: number;
showFiatRate: boolean;
disabled?: boolean;
}
export default function SendAmountInput({
balance,
amount,
currency,
onCurrencyChange,
onAmountChange,
onMaxClicked,
onMaxToggled,
fiatCurrency,
xmrPrice,
showFiatRate,
disabled = false,
}: SendAmountInputProps) {
const theme = useTheme();
const isMaxSelected = amount === "<MAX>";
// Calculate secondary amount for display
const secondaryAmount = (() => {
if (isMaxSelected) {
return "All available funds";
}
if (!amount || amount === "" || isNaN(parseFloat(amount))) {
return "0.00";
}
const primaryValue = parseFloat(amount);
if (currency === "XMR") {
// Primary is XMR, secondary is USD
return (primaryValue * xmrPrice).toFixed(2);
} else {
// Primary is USD, secondary is XMR
return (primaryValue / xmrPrice).toFixed(3);
}
})();
const handleMaxAmount = () => {
if (disabled) return;
if (onMaxToggled) {
onMaxToggled();
} else if (onMaxClicked) {
onMaxClicked();
} else {
// Fallback to old behavior if no callback provided
if (
balance?.unlocked_balance !== undefined &&
balance?.unlocked_balance !== null
) {
// TODO: We need to use a real fee here and call sweep(...) instead of just subtracting a fixed amount
const unlocked = parseFloat(balance.unlocked_balance);
const maxAmountXmr = piconerosToXmr(unlocked - 10000000000); // Subtract ~0.01 XMR for fees
if (currency === "XMR") {
onAmountChange(Math.max(0, maxAmountXmr).toString());
} else {
// Convert to USD for display
const maxAmountUsd = maxAmountXmr * xmrPrice;
onAmountChange(Math.max(0, maxAmountUsd).toString());
}
}
}
};
const handleMaxTextClick = () => {
if (disabled) return;
if (isMaxSelected && onMaxToggled) {
onMaxToggled();
}
};
const handleCurrencySwap = () => {
if (!isMaxSelected && !disabled) {
onCurrencyChange(currency === "XMR" ? fiatCurrency : "XMR");
}
};
const isAmountTooHigh =
!isMaxSelected &&
(currency === "XMR"
? parseFloat(amount) >
piconerosToXmr(parseFloat(balance.unlocked_balance))
: parseFloat(amount) / xmrPrice >
piconerosToXmr(parseFloat(balance.unlocked_balance)));
return (
<Card
elevation={0}
tabIndex={0}
sx={{
position: "relative",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
border: `1px solid ${theme.palette.grey[800]}`,
width: "100%",
height: 250,
opacity: disabled ? 0.6 : 1,
pointerEvents: disabled ? "none" : "auto",
}}
>
<Box
sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}
>
{isAmountTooHigh && (
<Grow
in
style={{ transitionDelay: isAmountTooHigh ? "100ms" : "0ms" }}
>
<Typography variant="caption" align="center" color="error">
You don't have enough
<br /> unlocked balance to send this amount.
</Typography>
</Grow>
)}
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1 }}>
{isMaxSelected ? (
<Typography
variant="h3"
onClick={handleMaxTextClick}
sx={{
fontWeight: 600,
color: "primary.main",
cursor: disabled ? "default" : "pointer",
userSelect: "none",
"&:hover": {
opacity: disabled ? 1 : 0.8,
},
}}
title={disabled ? "" : "Click to edit amount"}
>
&lt;MAX&gt;
</Typography>
) : (
<>
<NumberInput
value={amount}
onChange={disabled ? () => {} : onAmountChange}
placeholder={currency === "XMR" ? "0.000" : "0.00"}
fontSize="3em"
fontWeight={600}
minWidth={60}
step={currency === "XMR" ? 0.001 : 0.01}
largeStep={currency === "XMR" ? 0.1 : 10}
/>
<Typography variant="h4" color="text.secondary">
{currency}
</Typography>
</>
)}
</Box>
{showFiatRate && (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<SwapVertIcon
onClick={handleCurrencySwap}
sx={{
cursor: isMaxSelected || disabled ? "default" : "pointer",
opacity: isMaxSelected || disabled ? 0.5 : 1,
}}
/>
<Typography color="text.secondary">
{secondaryAmount}{" "}
{isMaxSelected ? "" : currency === "XMR" ? fiatCurrency : "XMR"}
</Typography>
</Box>
)}
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
width: "100%",
justifyContent: "center",
gap: 1.5,
position: "absolute",
bottom: 12,
left: 0,
}}
>
<Typography color="text.secondary">Available</Typography>
<Box sx={{ display: "flex", alignItems: "baseline", gap: 0.5 }}>
<Typography color="text.primary">
<MoneroAmount
amount={piconerosToXmr(parseFloat(balance.unlocked_balance))}
/>
</Typography>
<Typography color="text.secondary">XMR</Typography>
</Box>
<Button
variant={isMaxSelected ? "contained" : "secondary"}
size="tiny"
onClick={handleMaxAmount}
disabled={disabled}
>
Max
</Button>
</Box>
</Card>
);
}

View file

@ -0,0 +1,151 @@
import { useState, useEffect } from "react";
import {
DialogTitle,
DialogContent,
DialogActions,
Typography,
Box,
} from "@mui/material";
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import { resolveApproval } from "renderer/rpc";
import { usePendingSendMoneroApproval } from "store/hooks";
import { PiconeroAmount } from "renderer/components/other/Units";
import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
interface SendApprovalContentProps {
onClose: () => void;
}
export default function SendApprovalContent({
onClose,
}: SendApprovalContentProps) {
const pendingApprovals = usePendingSendMoneroApproval();
const [timeLeft, setTimeLeft] = useState<number>(0);
const approval = pendingApprovals[0]; // Handle the first approval request
useEffect(() => {
if (
!approval?.request_status ||
approval.request_status.state !== "Pending"
) {
return;
}
const expirationTs = approval.request_status.content.expiration_ts;
const expiresAtMs = expirationTs * 1000;
const tick = () => {
const remainingMs = Math.max(expiresAtMs - Date.now(), 0);
setTimeLeft(Math.ceil(remainingMs / 1000));
};
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [approval]);
const handleApprove = async () => {
if (!approval) throw new Error("No approval request available");
await resolveApproval(approval.request_id, true);
};
const handleReject = async () => {
if (!approval) throw new Error("No approval request available");
await resolveApproval(approval.request_id, false);
};
if (!approval) {
return null;
}
const { address, amount, fee } = approval.request.content;
return (
<>
<DialogTitle>
<Typography variant="h6" component="div">
Confirm Monero Transfer
</Typography>
</DialogTitle>
<DialogContent>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{/* Amount */}
<Box>
<Typography variant="subtitle2" gutterBottom>
Amount to Send
</Typography>
<Typography variant="h6" color="primary">
<PiconeroAmount amount={amount} fixedPrecision={12} />
</Typography>
</Box>
{/* Fee */}
<Box>
<Typography variant="subtitle2" gutterBottom>
Network Fee
</Typography>
<Typography variant="h6" color="text.secondary">
<PiconeroAmount amount={fee} fixedPrecision={12} />
</Typography>
</Box>
{/* Destination Address */}
<Box>
<Typography variant="subtitle2" gutterBottom>
Destination Address
</Typography>
<Typography variant="h6" color="text.secondary">
<ActionableMonospaceTextBox
content={address}
displayCopyIcon={true}
enableQrCode={false}
light={true}
/>
</Typography>
</Box>
{/* Time remaining */}
<Typography
variant="caption"
color="text.secondary"
sx={{ textAlign: "center" }}
>
{timeLeft > 0
? `Request expires in ${timeLeft}s`
: "Request expired"}
</Typography>
</Box>
</DialogContent>
<DialogActions sx={{ p: 3, gap: 1 }}>
<PromiseInvokeButton
onInvoke={handleReject}
onSuccess={onClose}
disabled={timeLeft === 0}
variant="outlined"
color="error"
startIcon={<CloseIcon />}
displayErrorSnackbar={true}
requiresContext={false}
>
Reject
</PromiseInvokeButton>
<PromiseInvokeButton
onInvoke={handleApprove}
disabled={timeLeft === 0}
variant="contained"
color="primary"
startIcon={<CheckIcon />}
displayErrorSnackbar={true}
requiresContext={false}
>
Send
</PromiseInvokeButton>
</DialogActions>
</>
);
}

View file

@ -0,0 +1,66 @@
import { Box, Button, Typography } from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { FiatPiconeroAmount, PiconeroAmount } from "renderer/components/other/Units";
import MonospaceTextBox from "renderer/components/other/MonospaceTextBox";
import ArrowOutwardIcon from "@mui/icons-material/ArrowOutward";
import { SendMoneroResponse } from "models/tauriModel";
import { getMoneroTxExplorerUrl } from "../../../../../utils/conversionUtils";
import { isTestnet } from "store/config";
import { open } from "@tauri-apps/plugin-shell";
export default function SendSuccessContent({
onClose,
successDetails,
}: {
onClose: () => void;
successDetails: SendMoneroResponse | null;
}) {
const address = successDetails?.address;
const amount = successDetails?.amount_sent;
const explorerUrl = getMoneroTxExplorerUrl(successDetails?.tx_hash, isTestnet());
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
minHeight: "400px",
minWidth: "500px",
gap: 7,
p: 4,
}}
>
<CheckCircleIcon sx={{ fontSize: 64, mt: 3 }} />
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 1,
}}
>
<Typography variant="h4">Transaction Published</Typography>
<Box sx={{ display: "flex", flexDirection: "row", alignItems: "center", gap: 1 }}>
<Typography variant="body1" color="text.secondary">Sent</Typography>
<Typography variant="body1" color="text.primary">
<PiconeroAmount amount={amount} fixedPrecision={4}/>
</Typography>
<Typography variant="body1" color="text.secondary">(<FiatPiconeroAmount amount={amount} />)</Typography>
</Box>
<Box sx={{ display: "flex", flexDirection: "row", alignItems: "center", gap: 1 }}>
<Typography variant="body1" color="text.secondary">to</Typography>
<Typography variant="body1" color="text.primary">
<MonospaceTextBox>{address.slice(0, 8)} ... {address.slice(-8)}</MonospaceTextBox>
</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
<Button onClick={onClose} variant="contained" color="primary">Done</Button>
<Button color="primary" size="small" endIcon={<ArrowOutwardIcon />} onClick={() => open(explorerUrl)}>View on Explorer</Button>
</Box>
</Box>
);
}

View file

@ -0,0 +1,179 @@
import {
Button,
Box,
DialogActions,
DialogContent,
DialogTitle,
Typography,
} from "@mui/material";
import { useState } from "react";
import { xmrToPiconeros } from "../../../../../utils/conversionUtils";
import SendAmountInput from "./SendAmountInput";
import MoneroAddressTextField from "renderer/components/inputs/MoneroAddressTextField";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import { sendMoneroTransaction } from "renderer/rpc";
import { useAppSelector } from "store/hooks";
import { SendMoneroResponse } from "models/tauriModel";
interface SendTransactionContentProps {
balance: {
unlocked_balance: string;
};
onClose: () => void;
onSuccess: (response: SendMoneroResponse) => void;
}
export default function SendTransactionContent({
balance,
onSuccess,
onClose,
}: SendTransactionContentProps) {
const [sendAddress, setSendAddress] = useState("");
const [sendAmount, setSendAmount] = useState("");
const [previousAmount, setPreviousAmount] = useState("");
const [enableSend, setEnableSend] = useState(false);
const [currency, setCurrency] = useState("XMR");
const [isMaxSelected, setIsMaxSelected] = useState(false);
const [isSending, setIsSending] = useState(false);
const showFiatRate = useAppSelector(
(state) => state.settings.fetchFiatPrices,
);
const fiatCurrency = useAppSelector((state) => state.settings.fiatCurrency);
const xmrPrice = useAppSelector((state) => state.rates.xmrPrice);
const handleCurrencyChange = (newCurrency: string) => {
if (!showFiatRate || !xmrPrice || isMaxSelected || isSending) {
return;
}
if (sendAmount === "" || parseFloat(sendAmount) === 0) {
setSendAmount(newCurrency === "XMR" ? "0.000" : "0.00");
} else {
setSendAmount(
newCurrency === "XMR"
? (parseFloat(sendAmount) / xmrPrice).toFixed(3)
: (parseFloat(sendAmount) * xmrPrice).toFixed(2),
);
}
setCurrency(newCurrency);
};
const handleMaxToggled = () => {
if (isSending) return;
if (isMaxSelected) {
// Disable MAX mode - restore previous amount
setIsMaxSelected(false);
setSendAmount(previousAmount);
} else {
// Enable MAX mode - save current amount first
setPreviousAmount(sendAmount);
setIsMaxSelected(true);
setSendAmount("<MAX>");
}
};
const handleAmountChange = (newAmount: string) => {
if (isSending) return;
if (newAmount !== "<MAX>") {
setIsMaxSelected(false);
}
setSendAmount(newAmount);
};
const handleAddressChange = (newAddress: string) => {
if (isSending) return;
setSendAddress(newAddress);
};
const moneroAmount =
currency === "XMR"
? parseFloat(sendAmount)
: parseFloat(sendAmount) / xmrPrice;
const handleSend = async () => {
if (!sendAddress) {
throw new Error("Address is required");
}
if (isMaxSelected) {
return sendMoneroTransaction({
address: sendAddress,
amount: { type: "Sweep" },
});
} else {
if (!sendAmount || sendAmount === "<MAX>") {
throw new Error("Amount is required");
}
return sendMoneroTransaction({
address: sendAddress,
amount: {
type: "Specific",
// Floor the amount to avoid rounding decimal amounts
// The amount is in piconeros, so it NEEDS to be a whole number
amount: Math.floor(xmrToPiconeros(moneroAmount)),
},
});
}
};
const handleSendSuccess = (response: SendMoneroResponse) => {
// Clear form after successful send
handleClear();
onSuccess(response);
};
const handleClear = () => {
setSendAddress("");
setSendAmount("");
setPreviousAmount("");
setIsMaxSelected(false);
};
const isSendDisabled =
!enableSend || (!isMaxSelected && (!sendAmount || sendAmount === "<MAX>"));
return (
<>
<DialogTitle>Send</DialogTitle>
<DialogContent>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<SendAmountInput
balance={balance}
amount={sendAmount}
onAmountChange={handleAmountChange}
onMaxToggled={handleMaxToggled}
currency={currency}
fiatCurrency={fiatCurrency}
xmrPrice={xmrPrice}
showFiatRate={showFiatRate}
onCurrencyChange={handleCurrencyChange}
disabled={isSending}
/>
<MoneroAddressTextField
address={sendAddress}
onAddressChange={handleAddressChange}
onAddressValidityChange={setEnableSend}
label="Send to"
fullWidth
disabled={isSending}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<PromiseInvokeButton
onInvoke={handleSend}
disabled={isSendDisabled}
onSuccess={handleSendSuccess}
onPendingChange={setIsSending}
>
Send
</PromiseInvokeButton>
</DialogActions>
</>
);
}

View file

@ -0,0 +1,58 @@
import { Box, darken, lighten, useTheme } from "@mui/material";
function getColor(colorName: string) {
const theme = useTheme();
switch (colorName) {
case "primary":
return theme.palette.primary.main;
case "secondary":
return theme.palette.secondary.main;
case "success":
return theme.palette.success.main;
case "warning":
return theme.palette.warning.main;
}
}
export default function StateIndicator({
color,
pulsating,
}: {
color: string;
pulsating: boolean;
}) {
const mainShade = getColor(color);
const darkShade = darken(mainShade, 0.4);
const glowShade = lighten(mainShade, 0.4);
const intensePulsatingStyles = {
animation: "pulse 2s infinite",
"@keyframes pulse": {
"0%": { opacity: 0.5 },
"50%": { opacity: 1 },
"100%": { opacity: 0.5 },
},
};
const softPulsatingStyles = {
animation: "pulse 3.5s infinite",
"@keyframes pulse": {
"0%": { opacity: 0.7 },
"50%": { opacity: 1 },
"100%": { opacity: 0.7 },
},
};
return (
<Box
sx={{
width: 10,
height: 10,
borderRadius: "50%",
backgroundImage: `radial-gradient(circle, ${mainShade}, ${darkShade})`,
boxShadow: `0 0 10px 0 ${glowShade}`,
...(pulsating ? intensePulsatingStyles : softPulsatingStyles),
}}
></Box>
);
}

View file

@ -0,0 +1,102 @@
import {
Typography,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
IconButton,
Tooltip,
Stack,
} from "@mui/material";
import { OpenInNew as OpenInNewIcon } from "@mui/icons-material";
import { open } from "@tauri-apps/plugin-shell";
import { PiconeroAmount } from "../../../other/Units";
import { getMoneroTxExplorerUrl } from "../../../../../utils/conversionUtils";
import { isTestnet } from "store/config";
import { TransactionInfo } from "models/tauriModel";
interface TransactionHistoryProps {
history?: {
transactions: TransactionInfo[];
};
}
// Component for displaying transaction history
export default function TransactionHistory({
history,
}: TransactionHistoryProps) {
if (!history || !history.transactions || history.transactions.length === 0) {
return <Typography variant="h5">Transaction History</Typography>;
}
return (
<>
<Typography variant="h5">Transaction History</Typography>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Amount</TableCell>
<TableCell>Fee</TableCell>
<TableCell align="right">Confirmations</TableCell>
<TableCell align="center">Explorer</TableCell>
</TableRow>
</TableHead>
<TableBody>
{[...history.transactions]
.sort((a, b) => a.confirmations - b.confirmations)
.map((tx, index) => (
<TableRow key={index}>
<TableCell>
<Stack direction="row" spacing={1} alignItems="center">
<PiconeroAmount amount={tx.amount} />
<Chip
label={tx.direction === "In" ? "Received" : "Sent"}
color={tx.direction === "In" ? "success" : "default"}
size="small"
/>
</Stack>
</TableCell>
<TableCell>
<PiconeroAmount amount={tx.fee} />
</TableCell>
<TableCell align="right">
<Chip
label={tx.confirmations}
color={tx.confirmations >= 10 ? "success" : "warning"}
size="small"
/>
</TableCell>
<TableCell align="center">
{tx.tx_hash && (
<Tooltip title="View on block explorer">
<IconButton
size="small"
onClick={() => {
const url = getMoneroTxExplorerUrl(
tx.tx_hash,
isTestnet(),
);
open(url);
}}
>
<OpenInNewIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
);
}

View file

@ -0,0 +1,144 @@
import {
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
ListItemIcon,
Menu,
MenuItem,
TextField,
Typography,
} from "@mui/material";
import {
Send as SendIcon,
SwapHoriz as SwapIcon,
Restore as RestoreIcon,
MoreHoriz as MoreHorizIcon,
} from "@mui/icons-material";
import { useState } from "react";
import { setMoneroRestoreHeight } from "renderer/rpc";
import SendTransactionModal from "../SendTransactionModal";
import { useNavigate } from "react-router-dom";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import SetRestoreHeightModal from "../SetRestoreHeightModal";
interface WalletActionButtonsProps {
balance: {
unlocked_balance: string;
};
}
function RestoreHeightDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const [restoreHeight, setRestoreHeight] = useState(0);
const handleRestoreHeight = async () => {
await setMoneroRestoreHeight(restoreHeight);
onClose();
};
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Restore Height</DialogTitle>
<DialogContent>
<TextField
label="Restore Height"
type="number"
value={restoreHeight}
onChange={(e) => setRestoreHeight(Number(e.target.value))}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<PromiseInvokeButton
onInvoke={handleRestoreHeight}
displayErrorSnackbar={true}
variant="contained"
>
Restore
</PromiseInvokeButton>
</DialogActions>
</Dialog>
);
}
export default function WalletActionButtons({
balance,
}: WalletActionButtonsProps) {
const navigate = useNavigate();
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [restoreHeightDialogOpen, setRestoreHeightDialogOpen] = useState(false);
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const menuOpen = Boolean(menuAnchorEl);
const handleMenuClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setMenuAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setMenuAnchorEl(null);
};
return (
<>
<SetRestoreHeightModal
open={restoreHeightDialogOpen}
onClose={() => setRestoreHeightDialogOpen(false)}
/>
<SendTransactionModal
balance={balance}
open={sendDialogOpen}
onClose={() => setSendDialogOpen(false)}
/>
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: 1,
mb: 2,
alignItems: "center",
}}
>
<Chip
icon={<SendIcon />}
label="Send"
variant="button"
clickable
onClick={() => setSendDialogOpen(true)}
/>
<Chip
onClick={() => navigate("/swap")}
icon={<SwapIcon />}
label="Swap"
variant="button"
clickable
/>
<IconButton onClick={handleMenuClick}>
<MoreHorizIcon />
</IconButton>
<Menu anchorEl={menuAnchorEl} open={menuOpen} onClose={handleMenuClose}>
<MenuItem
onClick={() => {
setRestoreHeightDialogOpen(true);
handleMenuClose();
}}
>
<ListItemIcon>
<RestoreIcon />
</ListItemIcon>
<Typography>Restore Height</Typography>
</MenuItem>
</Menu>
</Box>
</>
);
}

View file

@ -0,0 +1,148 @@
import {
Box,
Typography,
CircularProgress,
Button,
Card,
CardContent,
Divider,
CardHeader,
LinearProgress,
} from "@mui/material";
import { PiconeroAmount } from "../../../other/Units";
import { FiatPiconeroAmount } from "../../../other/Units";
import StateIndicator from "./StateIndicator";
interface WalletOverviewProps {
balance?: {
unlocked_balance: string;
total_balance: string;
};
syncProgress?: {
current_block: number;
target_block: number;
progress_percentage: number;
};
}
// Component for displaying wallet address and balance
export default function WalletOverview({
balance,
syncProgress,
}: WalletOverviewProps) {
const pendingBalance =
parseFloat(balance.total_balance) - parseFloat(balance.unlocked_balance);
const isSyncing = syncProgress && syncProgress.progress_percentage < 100;
const blocksLeft = syncProgress?.target_block - syncProgress?.current_block;
return (
<Card sx={{ p: 2, position: "relative", borderRadius: 2 }} elevation={4}>
{syncProgress && syncProgress.progress_percentage < 100 && (
<LinearProgress
value={syncProgress.progress_percentage}
variant="determinate"
sx={{
width: "100%",
position: "absolute",
top: 0,
left: 0,
}}
/>
)}
{/* Balance */}
<Box
sx={{
display: "grid",
gridTemplateColumns: "1.5fr 1fr max-content",
rowGap: 0.5,
columnGap: 2,
mb: 1,
}}
>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 1, gridColumn: "1", gridRow: "1" }}
>
Available Funds
</Typography>
<Typography variant="h4" sx={{ gridColumn: "1", gridRow: "2" }}>
<PiconeroAmount
amount={parseFloat(balance.unlocked_balance)}
fixedPrecision={4}
/>
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ gridColumn: "1", gridRow: "3" }}
>
<FiatPiconeroAmount amount={parseFloat(balance.unlocked_balance)} />
</Typography>
{pendingBalance > 0 && (
<>
<Typography
variant="body2"
color="warning"
sx={{
mb: 1,
animation: "pulse 2s infinite",
gridColumn: "2",
gridRow: "1",
alignSelf: "end",
}}
>
Pending
</Typography>
<Typography
variant="h5"
sx={{ gridColumn: "2", gridRow: "2", alignSelf: "center" }}
>
<PiconeroAmount amount={pendingBalance} fixedPrecision={4} />
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ gridColumn: "2", gridRow: "3" }}
>
<FiatPiconeroAmount amount={pendingBalance} />
</Typography>
</>
)}
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 1,
}}
>
<Typography variant="body2">
{isSyncing ? "syncing" : "synced"}
</Typography>
<StateIndicator
color={isSyncing ? "primary" : "success"}
pulsating={isSyncing}
/>
</Box>
{isSyncing && (
<Typography variant="body2" color="text.secondary">
{blocksLeft.toLocaleString()} blocks left
</Typography>
)}
</Box>
</Box>
</Card>
);
}

View file

@ -0,0 +1,87 @@
import { Box, Card, Chip, Skeleton, Typography } from "@mui/material";
import StateIndicator from "./StateIndicator";
import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox";
const DUMMY_ADDRESS =
"888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H";
export default function WalletPageLoadingState() {
return (
<Box
sx={{
maxWidth: 800,
mx: "auto",
display: "flex",
flexDirection: "column",
gap: 2,
pb: 2,
}}
>
<Card sx={{ p: 2, position: "relative", borderRadius: 2 }} elevation={4}>
{/* Balance */}
<Box
sx={{
display: "grid",
gridTemplateColumns: "1.5fr 1fr max-content",
rowGap: 0.5,
columnGap: 2,
mb: 1,
}}
>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 1, gridColumn: "1", gridRow: "1" }}
>
Available Funds
</Typography>
<Typography variant="h4" sx={{ gridColumn: "1", gridRow: "2" }}>
<Skeleton variant="text" width="80%" />
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ gridColumn: "1", gridRow: "3" }}
>
<Skeleton variant="text" width="40%" />
</Typography>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 1,
}}
>
<Typography variant="body2">loading</Typography>
<StateIndicator color="primary" pulsating={true} />
</Box>
</Box>
</Box>
</Card>
<Skeleton variant="rounded" width="100%">
<ActionableMonospaceTextBox content={DUMMY_ADDRESS} />
</Skeleton>
<Box sx={{ display: "flex", flexDirection: "row", gap: 2, mb: 2 }}>
{Array.from({ length: 2 }).map((_) => (
<Skeleton variant="rounded" sx={{ borderRadius: "100px" }}>
<Chip label="Loading..." variant="button" />
</Skeleton>
))}
</Box>
<Typography variant="h5">Transaction History</Typography>
<Skeleton variant="rounded" width="100%" height={40} />
</Box>
);
}

View file

@ -0,0 +1,5 @@
export { default as WalletOverview } from "./WalletOverview";
export { default as TransactionHistory } from "./TransactionHistory";
export { default as WalletActionButtons } from "./WalletActionButtons";
export { default as SendTransactionContent } from "./SendTransactionContent";
export { default as SendApprovalContent } from "./SendApprovalContent";

View file

@ -1,5 +1,5 @@
import React, { ReactNode } from "react";
import { Box, Link, Typography } from "@mui/material"; import { Box, Link, Typography } from "@mui/material";
import { ReactNode } from "react";
import InfoBox from "./InfoBox"; import InfoBox from "./InfoBox";
import TruncatedText from "renderer/components/other/TruncatedText"; import TruncatedText from "renderer/components/other/TruncatedText";

View file

@ -14,8 +14,8 @@ export default function InitPage() {
useState(false); useState(false);
// We force this to true for now because the internal wallet is not really accessible from the GUI yet // We force this to true for now because the internal wallet is not really accessible from the GUI yet
const [useExternalRedeemAddress, _setUseExternalRedeemAddress] = const [useExternalRedeemAddress, setUseExternalRedeemAddress] =
useState(true); useState(true);
const [redeemAddressValid, setRedeemAddressValid] = useState(false); const [redeemAddressValid, setRedeemAddressValid] = useState(false);
const [refundAddressValid, setRefundAddressValid] = useState(false); const [refundAddressValid, setRefundAddressValid] = useState(false);
@ -40,14 +40,35 @@ export default function InitPage() {
}} }}
> >
<Paper variant="outlined" style={{}}> <Paper variant="outlined" style={{}}>
<MoneroAddressTextField <Tabs
label="Monero redeem address" value={useExternalRedeemAddress ? 1 : 0}
address={redeemAddress} indicatorColor="primary"
onAddressChange={setRedeemAddress} variant="fullWidth"
onAddressValidityChange={setRedeemAddressValid} onChange={(_, newValue) =>
fullWidth setUseExternalRedeemAddress(newValue === 1)
helperText="The monero will be sent to this address" }
/> >
<Tab label="Redeem to internal Monero wallet" value={0} />
<Tab label="Redeem to external Monero address" value={1} />
</Tabs>
<Box style={{ padding: "16px" }}>
{useExternalRedeemAddress ? (
<MoneroAddressTextField
label="External Monero redeem address"
address={redeemAddress}
onAddressChange={setRedeemAddress}
onAddressValidityChange={setRedeemAddressValid}
helperText="The monero will be sent to this address if the swap is successful."
fullWidth
/>
) : (
<Typography variant="caption">
The Monero will be sent to the internal Monero wallet of the
GUI. You can then withdraw them from there or use them for
another swap directly.
</Typography>
)}
</Box>
</Paper> </Paper>
<Paper variant="outlined" style={{}}> <Paper variant="outlined" style={{}}>

View file

@ -11,7 +11,6 @@ export default function WalletPage() {
gap: "1rem", gap: "1rem",
}} }}
> >
<Typography variant="h3">Wallet</Typography>
<Alert severity="info"> <Alert severity="info">
You do not have to deposit money before starting a swap. Instead, you You do not have to deposit money before starting a swap. Instead, you
will be greeted with a deposit address after you initiate one. will be greeted with a deposit address after you initiate one.

View file

@ -1,6 +1,23 @@
import { createTheme, ThemeOptions } from "@mui/material"; import { createTheme, ThemeOptions } from "@mui/material";
import { indigo } from "@mui/material/colors"; import { indigo } from "@mui/material/colors";
// Extend the theme to include custom chip variants
declare module "@mui/material/Chip" {
interface ChipPropsVariantOverrides {
button: true;
}
}
// Extend the theme to include custom button variants and sizes
declare module "@mui/material/Button" {
interface ButtonPropsVariantOverrides {
secondary: true;
}
interface ButtonPropsSizeOverrides {
tiny: true;
}
}
export enum Theme { export enum Theme {
Light = "light", Light = "light",
Dark = "dark", Dark = "dark",
@ -33,7 +50,61 @@ const baseTheme: ThemeOptions = {
backgroundColor: "color-mix(in srgb, #bdbdbd 10%, transparent)", backgroundColor: "color-mix(in srgb, #bdbdbd 10%, transparent)",
}, },
}, },
sizeTiny: {
fontSize: "0.75rem",
fontWeight: 500,
padding: "4px 8px",
minHeight: "24px",
minWidth: "auto",
lineHeight: 1.2,
textTransform: "none",
borderRadius: "4px",
},
}, },
variants: [
{
props: { variant: "secondary" },
style: ({ theme }) => ({
backgroundColor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.08)"
: "rgba(0, 0, 0, 0.04)",
color: theme.palette.text.secondary,
"&:hover": {
backgroundColor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.12)"
: "rgba(0, 0, 0, 0.08)",
borderColor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.23)"
: "rgba(0, 0, 0, 0.23)",
},
"&:disabled": {
backgroundColor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.04)"
: "rgba(0, 0, 0, 0.02)",
color: theme.palette.text.disabled,
borderColor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.08)"
: "rgba(0, 0, 0, 0.08)",
},
}),
},
],
},
MuiChip: {
variants: [
{
props: { variant: "button" },
style: ({ theme }) => ({
padding: "12px 16px",
cursor: "pointer",
}),
},
],
}, },
MuiDialog: { MuiDialog: {
defaultProps: { defaultProps: {

View file

@ -31,16 +31,31 @@ import {
RedactResponse, RedactResponse,
GetCurrentSwapResponse, GetCurrentSwapResponse,
LabeledMoneroAddress, LabeledMoneroAddress,
GetPendingApprovalsArgs, GetMoneroHistoryResponse,
GetMoneroMainAddressResponse,
GetMoneroBalanceResponse,
SendMoneroArgs,
SendMoneroResponse,
GetMoneroSyncProgressResponse,
GetPendingApprovalsResponse, GetPendingApprovalsResponse,
RejectApprovalArgs,
RejectApprovalResponse,
SetRestoreHeightArgs,
SetRestoreHeightResponse,
GetRestoreHeightResponse,
} from "models/tauriModel"; } from "models/tauriModel";
import { import {
rpcSetBalance, rpcSetBalance,
rpcSetSwapInfo, rpcSetSwapInfo,
approvalRequestsReplaced, approvalRequestsReplaced,
} from "store/features/rpcSlice"; } from "store/features/rpcSlice";
import {
setMainAddress,
setBalance,
setSyncProgress,
setHistory,
} from "store/features/walletSlice";
import { store } from "./store/storeRenderer"; import { store } from "./store/storeRenderer";
import { Maker } from "models/apiModel";
import { providerToConcatenatedMultiAddr } from "utils/multiAddrUtils"; import { providerToConcatenatedMultiAddr } from "utils/multiAddrUtils";
import { MoneroRecoveryResponse } from "models/rpcModel"; import { MoneroRecoveryResponse } from "models/rpcModel";
import { ListSellersResponse } from "../models/tauriModel"; import { ListSellersResponse } from "../models/tauriModel";
@ -417,6 +432,129 @@ export async function getMoneroAddresses(): Promise<GetMoneroAddressesResponse>
return await invokeNoArgs<GetMoneroAddressesResponse>("get_monero_addresses"); return await invokeNoArgs<GetMoneroAddressesResponse>("get_monero_addresses");
} }
export async function getRestoreHeight(): Promise<GetRestoreHeightResponse> {
return await invokeNoArgs<GetRestoreHeightResponse>("get_restore_height");
}
export async function setMoneroRestoreHeight(
height: number | Date,
): Promise<SetRestoreHeightResponse> {
const args: SetRestoreHeightArgs =
typeof height === "number"
? { type: "Height", height: height }
: {
type: "Date",
height: {
year: height.getFullYear(),
month: height.getMonth() + 1, // JavaScript months are 0-indexed, but we want 1-indexed
day: height.getDate(),
},
};
return await invoke<SetRestoreHeightArgs, SetRestoreHeightResponse>(
"set_monero_restore_height",
args,
);
}
export async function getMoneroHistory(): Promise<GetMoneroHistoryResponse> {
return await invokeNoArgs<GetMoneroHistoryResponse>("get_monero_history");
}
export async function getMoneroMainAddress(): Promise<GetMoneroMainAddressResponse> {
return await invokeNoArgs<GetMoneroMainAddressResponse>(
"get_monero_main_address",
);
}
export async function getMoneroBalance(): Promise<GetMoneroBalanceResponse> {
return await invokeNoArgs<GetMoneroBalanceResponse>("get_monero_balance");
}
export async function sendMonero(
args: SendMoneroArgs,
): Promise<SendMoneroResponse> {
return await invoke<SendMoneroArgs, SendMoneroResponse>("send_monero", args);
}
export async function getMoneroSyncProgress(): Promise<GetMoneroSyncProgressResponse> {
return await invokeNoArgs<GetMoneroSyncProgressResponse>(
"get_monero_sync_progress",
);
}
// Wallet management functions that handle Redux dispatching
export async function initializeMoneroWallet() {
try {
const [
addressResponse,
balanceResponse,
syncProgressResponse,
historyResponse,
] = await Promise.all([
getMoneroMainAddress(),
getMoneroBalance(),
getMoneroSyncProgress(),
getMoneroHistory(),
]);
store.dispatch(setMainAddress(addressResponse.address));
store.dispatch(setBalance(balanceResponse));
store.dispatch(setSyncProgress(syncProgressResponse));
store.dispatch(setHistory(historyResponse));
} catch (err) {
console.error("Failed to fetch Monero wallet data:", err);
}
}
export async function sendMoneroTransaction(
args: SendMoneroArgs,
): Promise<SendMoneroResponse> {
try {
const response = await sendMonero(args);
// Refresh balance and history after sending - but don't let this block the response
Promise.all([
getMoneroBalance(),
getMoneroHistory(),
]).then(([newBalance, newHistory]) => {
store.dispatch(setBalance(newBalance));
store.dispatch(setHistory(newHistory));
}).catch(refreshErr => {
console.error("Failed to refresh wallet data after send:", refreshErr);
// Could emit a toast notification here
});
return response;
} catch (err) {
console.error("Failed to send Monero:", err);
throw err; // ✅ Re-throw so caller can handle appropriately
}
}
async function refreshWalletDataAfterTransaction() {
try {
const [newBalance, newHistory] = await Promise.all([
getMoneroBalance(),
getMoneroHistory(),
]);
store.dispatch(setBalance(newBalance));
store.dispatch(setHistory(newHistory));
} catch (err) {
console.error("Failed to refresh wallet data after transaction:", err);
// Maybe show a non-blocking notification to user
}
}
export async function updateMoneroSyncProgress() {
try {
const response = await getMoneroSyncProgress();
store.dispatch(setSyncProgress(response));
} catch (err) {
console.error("Failed to fetch sync progress:", err);
}
}
export async function getDataDir(): Promise<string> { export async function getDataDir(): Promise<string> {
const testnet = isTestnet(); const testnet = isTestnet();
return await invoke<GetDataDirArgs, string>("get_data_dir", { return await invoke<GetDataDirArgs, string>("get_data_dir", {
@ -424,22 +562,37 @@ export async function getDataDir(): Promise<string> {
}); });
} }
export async function resolveApproval( export async function resolveApproval<T>(
requestId: string, requestId: string,
accept: object, accept: T,
): Promise<void> { ): Promise<void> {
try { try {
await invoke<ResolveApprovalArgs, ResolveApprovalResponse>( await invoke<ResolveApprovalArgs, ResolveApprovalResponse>(
"resolve_approval_request", "resolve_approval_request",
{ request_id: requestId, accept }, { request_id: requestId, accept: accept as object },
); );
} catch (error) { } finally {
// Refresh approval list when resolve fails to keep UI in sync // Always refresh the approval list
await refreshApprovals(); await refreshApprovals();
throw error;
// Refresh the approval list a few miliseconds later to again
// Just to make sure :)
setTimeout(() => {
refreshApprovals();
}, 200);
} }
} }
export async function rejectApproval<T>(
requestId: string,
reject: T,
): Promise<void> {
await invoke<RejectApprovalArgs, RejectApprovalResponse>(
"reject_approval_request",
{ request_id: requestId },
);
}
export async function refreshApprovals(): Promise<void> { export async function refreshApprovals(): Promise<void> {
const response = await invokeNoArgs<GetPendingApprovalsResponse>( const response = await invokeNoArgs<GetPendingApprovalsResponse>(
"get_pending_approvals", "get_pending_approvals",

View file

@ -7,6 +7,7 @@ import settingsSlice from "./features/settingsSlice";
import nodesSlice from "./features/nodesSlice"; import nodesSlice from "./features/nodesSlice";
import conversationsSlice from "./features/conversationsSlice"; import conversationsSlice from "./features/conversationsSlice";
import poolSlice from "./features/poolSlice"; import poolSlice from "./features/poolSlice";
import walletSlice from "./features/walletSlice";
export const reducers = { export const reducers = {
swap: swapReducer, swap: swapReducer,
@ -18,4 +19,5 @@ export const reducers = {
nodes: nodesSlice, nodes: nodesSlice,
conversations: conversationsSlice, conversations: conversationsSlice,
pool: poolSlice, pool: poolSlice,
wallet: walletSlice,
}; };

View file

@ -156,6 +156,18 @@ export const rpcSlice = createSlice({
backgroundProgressEventRemoved(slice, action: PayloadAction<string>) { backgroundProgressEventRemoved(slice, action: PayloadAction<string>) {
delete slice.state.background[action.payload]; delete slice.state.background[action.payload];
}, },
rpcSetBackgroundItems(
slice,
action: PayloadAction<{ [key: string]: TauriBackgroundProgress }>,
) {
slice.state.background = action.payload;
},
rpcSetApprovalItems(
slice,
action: PayloadAction<{ [requestId: string]: ApprovalRequest }>,
) {
slice.state.approvalRequests = action.payload;
},
}, },
}); });
@ -175,6 +187,8 @@ export const {
approvalRequestsReplaced, approvalRequestsReplaced,
backgroundProgressEventReceived, backgroundProgressEventReceived,
backgroundProgressEventRemoved, backgroundProgressEventRemoved,
rpcSetBackgroundItems,
rpcSetApprovalItems,
} = rpcSlice.actions; } = rpcSlice.actions;
export default rpcSlice.reducer; export default rpcSlice.reducer;

View file

@ -0,0 +1,65 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import {
GetMoneroBalanceResponse,
GetMoneroHistoryResponse,
GetMoneroSyncProgressResponse,
} from "models/tauriModel";
interface WalletState {
// Wallet data
mainAddress: string | null;
balance: GetMoneroBalanceResponse | null;
syncProgress: GetMoneroSyncProgressResponse | null;
history: GetMoneroHistoryResponse | null;
}
export interface WalletSlice {
state: WalletState;
}
const initialState: WalletSlice = {
state: {
// Wallet data
mainAddress: null,
balance: null,
syncProgress: null,
history: null,
},
};
export const walletSlice = createSlice({
name: "wallet",
initialState,
reducers: {
// Wallet data actions
setMainAddress(slice, action: PayloadAction<string>) {
slice.state.mainAddress = action.payload;
},
setBalance(slice, action: PayloadAction<GetMoneroBalanceResponse>) {
slice.state.balance = action.payload;
},
setSyncProgress(
slice,
action: PayloadAction<GetMoneroSyncProgressResponse>,
) {
slice.state.syncProgress = action.payload;
},
setHistory(slice, action: PayloadAction<GetMoneroHistoryResponse>) {
slice.state.history = action.payload;
},
// Reset actions
resetWalletState(slice) {
slice.state = initialState.state;
},
},
});
export const {
setMainAddress,
setBalance,
setSyncProgress,
setHistory,
resetWalletState,
} = walletSlice.actions;
export default walletSlice.reducer;

View file

@ -12,6 +12,10 @@ import {
isPendingSelectMakerApprovalEvent, isPendingSelectMakerApprovalEvent,
haveFundsBeenLocked, haveFundsBeenLocked,
PendingSeedSelectionApprovalRequest, PendingSeedSelectionApprovalRequest,
PendingSendMoneroApprovalRequest,
isPendingSendMoneroApprovalEvent,
PendingPasswordApprovalRequest,
isPendingPasswordApprovalEvent,
} from "models/tauriModelExt"; } from "models/tauriModelExt";
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { AppDispatch, RootState } from "renderer/store/storeRenderer"; import type { AppDispatch, RootState } from "renderer/store/storeRenderer";
@ -207,6 +211,11 @@ export function usePendingLockBitcoinApproval(): PendingLockBitcoinApprovalReque
return approvals.filter((c) => isPendingLockBitcoinApprovalEvent(c)); return approvals.filter((c) => isPendingLockBitcoinApprovalEvent(c));
} }
export function usePendingSendMoneroApproval(): PendingSendMoneroApprovalRequest[] {
const approvals = usePendingApprovals();
return approvals.filter((c) => isPendingSendMoneroApprovalEvent(c));
}
export function usePendingSelectMakerApproval(): PendingSelectMakerApprovalRequest[] { export function usePendingSelectMakerApproval(): PendingSelectMakerApprovalRequest[] {
const approvals = usePendingApprovals(); const approvals = usePendingApprovals();
return approvals.filter((c) => isPendingSelectMakerApprovalEvent(c)); return approvals.filter((c) => isPendingSelectMakerApprovalEvent(c));
@ -217,6 +226,11 @@ export function usePendingSeedSelectionApproval(): PendingSeedSelectionApprovalR
return approvals.filter((c) => isPendingSeedSelectionApprovalEvent(c)); return approvals.filter((c) => isPendingSeedSelectionApprovalEvent(c));
} }
export function usePendingPasswordApproval(): PendingPasswordApprovalRequest[] {
const approvals = usePendingApprovals();
return approvals.filter((c) => isPendingPasswordApprovalEvent(c));
}
/// Returns all the pending background processes /// Returns all the pending background processes
/// In the format [id, {componentName, {type: "Pending", content: {consumed, total}}}] /// In the format [id, {componentName, {type: "Pending", content: {consumed, total}}}]
export function usePendingBackgroundProcesses(): [ export function usePendingBackgroundProcesses(): [

View file

@ -6,6 +6,7 @@ import {
updateAllNodeStatuses, updateAllNodeStatuses,
fetchSellersAtPresetRendezvousPoints, fetchSellersAtPresetRendezvousPoints,
getSwapInfo, getSwapInfo,
initializeMoneroWallet,
} 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";
@ -69,6 +70,7 @@ export function createMainListeners() {
checkBitcoinBalance(), checkBitcoinBalance(),
getAllSwapInfos(), getAllSwapInfos(),
fetchSellersAtPresetRendezvousPoints(), fetchSellersAtPresetRendezvousPoints(),
initializeMoneroWallet(),
]); ]);
} }
}, },

View file

@ -15,6 +15,10 @@ export function piconerosToXmr(piconeros: number): number {
return piconeros / 1000000000000; return piconeros / 1000000000000;
} }
export function xmrToPiconeros(xmr: number): number {
return Math.ceil(xmr * 1000000000000);
}
export function isXmrAddressValid(address: string, stagenet: boolean) { export function isXmrAddressValid(address: string, stagenet: boolean) {
const re = stagenet const re = stagenet
? "^(?:[57][0-9A-Za-z]{94}|[57][0-9A-Za-z]{105})$" ? "^(?:[57][0-9A-Za-z]{94}|[57][0-9A-Za-z]{105})$"

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -4,21 +4,21 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
serde = { workspace = true }
time = "0.3"
swap-serde = { path = "../swap-serde" }
bitcoin = { workspace = true }
monero = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tracing = { workspace = true } bitcoin = { workspace = true }
swap-fs = { path = "../swap-fs" }
dialoguer = "0.11"
config = { version = "0.14", default-features = false, features = ["toml"] } config = { version = "0.14", default-features = false, features = ["toml"] }
dialoguer = "0.11"
libp2p = { workspace = true, features = ["serde"] } libp2p = { workspace = true, features = ["serde"] }
thiserror = { workspace = true } monero = { workspace = true }
rust_decimal = { workspace = true } rust_decimal = { workspace = true }
url = { workspace = true } serde = { workspace = true }
swap-fs = { path = "../swap-fs" }
swap-serde = { path = "../swap-serde" }
thiserror = { workspace = true }
time = "0.3"
toml = "0.8" toml = "0.8"
tracing = { workspace = true }
url = { workspace = true }
[lints] [lints]
workspace = true workspace = true

View file

@ -1,5 +1,4 @@
use crate::env::{Mainnet, Testnet}; use crate::env::{Mainnet, Testnet};
use swap_fs::{ensure_directory_exists, system_config_dir, system_data_dir};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use config::ConfigError; use config::ConfigError;
use dialoguer::theme::ColorfulTheme; use dialoguer::theme::ColorfulTheme;
@ -12,6 +11,7 @@ use std::ffi::OsStr;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::str::FromStr; use std::str::FromStr;
use swap_fs::{ensure_directory_exists, system_config_dir, system_data_dir};
use url::Url; use url::Url;
pub trait GetDefaults { pub trait GetDefaults {
@ -130,9 +130,15 @@ pub struct Data {
pub struct Network { pub struct Network {
#[serde(deserialize_with = "swap_serde::libp2p::multiaddresses::deserialize")] #[serde(deserialize_with = "swap_serde::libp2p::multiaddresses::deserialize")]
pub listen: Vec<Multiaddr>, pub listen: Vec<Multiaddr>,
#[serde(default, deserialize_with = "swap_serde::libp2p::multiaddresses::deserialize")] #[serde(
default,
deserialize_with = "swap_serde::libp2p::multiaddresses::deserialize"
)]
pub rendezvous_point: Vec<Multiaddr>, pub rendezvous_point: Vec<Multiaddr>,
#[serde(default, deserialize_with = "swap_serde::libp2p::multiaddresses::deserialize")] #[serde(
default,
deserialize_with = "swap_serde::libp2p::multiaddresses::deserialize"
)]
pub external_addresses: Vec<Multiaddr>, pub external_addresses: Vec<Multiaddr>,
} }
@ -259,7 +265,7 @@ pub fn query_user_for_initial_config(testnet: bool) -> Result<Config> {
.to_string(), .to_string(),
) )
.interact_text()?; .interact_text()?;
let data_dir = data_dir.as_str().parse()?; let data_dir: PathBuf = data_dir.as_str().parse()?;
let target_block = Input::with_theme(&ColorfulTheme::default()) let target_block = Input::with_theme(&ColorfulTheme::default())
.with_prompt("How fast should your Bitcoin transactions be confirmed? Your transaction fee will be calculated based on this target. Hit return to use default") .with_prompt("How fast should your Bitcoin transactions be confirmed? Your transaction fee will be calculated based on this target. Hit return to use default")
@ -373,7 +379,7 @@ pub fn query_user_for_initial_config(testnet: bool) -> Result<Config> {
println!(); println!();
Ok(Config { Ok(Config {
data: Data { dir: data_dir }, data: Data { dir: data_dir},
network: Network { network: Network {
listen: listen_addresses, listen: listen_addresses,
rendezvous_point: rendezvous_points, // keeping the singular key name for backcompat rendezvous_point: rendezvous_points, // keeping the singular key name for backcompat

View file

@ -1,8 +1,8 @@
use crate::config::Config as AsbConfig;
use serde::Serialize; use serde::Serialize;
use std::cmp::max; use std::cmp::max;
use std::time::Duration; use std::time::Duration;
use time::ext::NumericalStdDuration; use time::ext::NumericalStdDuration;
use crate::config::Config as AsbConfig;
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize)]
pub struct Config { pub struct Config {
@ -136,8 +136,6 @@ pub fn new(is_testnet: bool, asb_config: &AsbConfig) -> Config {
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -1,2 +1,2 @@
pub mod config;
pub mod env; pub mod env;
pub mod config;

View file

@ -20,6 +20,17 @@ pub fn system_data_dir() -> Result<PathBuf> {
.context("Could not generate default system data-dir dir path") .context("Could not generate default system data-dir dir path")
} }
pub fn system_data_dir_eigenwallet(testnet: bool) -> Result<PathBuf> {
let application_directory = match testnet {
true => "eigenwallet-testnet",
false => "eigenwallet",
};
ProjectDirs::from("", "", application_directory)
.map(|proj_dirs| proj_dirs.data_dir().to_path_buf())
.context("Could not generate default system data-dir dir path")
}
pub fn ensure_directory_exists(file: &Path) -> Result<(), std::io::Error> { pub fn ensure_directory_exists(file: &Path) -> Result<(), std::io::Error> {
if let Some(path) = file.parent() { if let Some(path) = file.parent() {
if !path.exists() { if !path.exists() {

View file

@ -4,15 +4,15 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
serde = { workspace = true }
monero = { workspace = true }
bitcoin = { workspace = true }
libp2p = { workspace = true }
serde_json = { workspace = true }
url = { workspace = true }
hex = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
bitcoin = { workspace = true }
hex = { workspace = true }
libp2p = { workspace = true }
monero = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
url = { workspace = true }
[lints] [lints]
workspace = true workspace = true

View file

@ -1,5 +1,5 @@
use bitcoin::Network;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use bitcoin::{Network};
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
#[serde(remote = "Network")] #[serde(remote = "Network")]
@ -73,4 +73,4 @@ pub mod address_serde {
} }
} }
} }
} }

View file

@ -37,4 +37,4 @@ pub mod urls {
)), )),
} }
} }
} }

View file

@ -1,4 +1,4 @@
pub mod monero;
pub mod bitcoin; pub mod bitcoin;
pub mod electrum;
pub mod libp2p; pub mod libp2p;
pub mod electrum; pub mod monero;

View file

@ -3,7 +3,7 @@ pub mod multiaddresses {
use serde::de::Unexpected; use serde::de::Unexpected;
use serde::{de, Deserialize, Deserializer}; use serde::{de, Deserialize, Deserializer};
use serde_json::Value; use serde_json::Value;
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Multiaddr>, D::Error> pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Multiaddr>, D::Error>
where where
D: Deserializer<'de>, D: Deserializer<'de>,
@ -24,17 +24,17 @@ pub mod multiaddresses {
.map(|v| { .map(|v| {
if let Value::String(s) = v { if let Value::String(s) = v {
s.trim().parse().map_err(de::Error::custom) s.trim().parse().map_err(de::Error::custom)
} else { } else {
Err(de::Error::custom("expected a string")) Err(de::Error::custom("expected a string"))
} }
}) })
.collect(); .collect();
Ok(list?) Ok(list?)
}
value => Err(de::Error::invalid_type(
Unexpected::Other(&value.to_string()),
&"a string or array",
)),
} }
value => Err(de::Error::invalid_type(
Unexpected::Other(&value.to_string()),
&"a string or array",
)),
}
} }
} }

View file

@ -1,4 +1,4 @@
use monero::{Network, Amount}; use monero::{Amount, Network};
use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -11,6 +11,7 @@ pub enum network {
} }
pub mod private_key { pub mod private_key {
use hex;
use monero::consensus::{Decodable, Encodable}; use monero::consensus::{Decodable, Encodable};
use monero::PrivateKey; use monero::PrivateKey;
use serde::de::Visitor; use serde::de::Visitor;
@ -18,7 +19,6 @@ pub mod private_key {
use serde::{de, Deserializer, Serializer}; use serde::{de, Deserializer, Serializer};
use std::fmt; use std::fmt;
use std::io::Cursor; use std::io::Cursor;
use hex;
struct BytesVisitor; struct BytesVisitor;
@ -143,4 +143,4 @@ pub mod address {
}; };
validate(address, expected_network) validate(address, expected_network)
} }
} }

View file

@ -12,9 +12,7 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [ "nullable": [false]
false
]
}, },
"hash": "081c729a0f1ad6e4ff3e13d6702c946bc4d37d50f40670b4f51d2efcce595aa6" "hash": "081c729a0f1ad6e4ff3e13d6702c946bc4d37d50f40670b4f51d2efcce595aa6"
} }

View file

@ -12,9 +12,7 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [ "nullable": [true]
true
]
}, },
"hash": "0d465a17ebbb5761421def759c73cad023c30705d5b41a1399ef79d8d2571d7c" "hash": "0d465a17ebbb5761421def759c73cad023c30705d5b41a1399ef79d8d2571d7c"
} }

View file

@ -12,9 +12,7 @@
"parameters": { "parameters": {
"Right": 0 "Right": 0
}, },
"nullable": [ "nullable": [true]
true
]
}, },
"hash": "1f332be08a5426f3fbcadea4e755d82ff1cdc2690eb464ccc607d3a613fa76a1" "hash": "1f332be08a5426f3fbcadea4e755d82ff1cdc2690eb464ccc607d3a613fa76a1"
} }

View file

@ -17,10 +17,7 @@
"parameters": { "parameters": {
"Right": 0 "Right": 0
}, },
"nullable": [ "nullable": [true, true]
true,
true
]
}, },
"hash": "5cc61dd0315571bc198401a354cd9431ee68360941f341386cbacf44ea598de8" "hash": "5cc61dd0315571bc198401a354cd9431ee68360941f341386cbacf44ea598de8"
} }

View file

@ -17,10 +17,7 @@
"parameters": { "parameters": {
"Right": 0 "Right": 0
}, },
"nullable": [ "nullable": [false, false]
false,
false
]
}, },
"hash": "6130b6cdd184181f890964eb460741f5cf23b5237fb676faed009106627a4ca6" "hash": "6130b6cdd184181f890964eb460741f5cf23b5237fb676faed009106627a4ca6"
} }

View file

@ -12,9 +12,7 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [ "nullable": [false]
false
]
}, },
"hash": "88f761a4f7a0429cad1df0b1bebb1c0a27b2a45656549b23076d7542cfa21ecf" "hash": "88f761a4f7a0429cad1df0b1bebb1c0a27b2a45656549b23076d7542cfa21ecf"
} }

View file

@ -12,9 +12,7 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [ "nullable": [false]
false
]
}, },
"hash": "d78acba5eb8563826dd190e0886aa665aae3c6f1e312ee444e65df1c95afe8b2" "hash": "d78acba5eb8563826dd190e0886aa665aae3c6f1e312ee444e65df1c95afe8b2"
} }

View file

@ -22,11 +22,7 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [ "nullable": [true, false, false]
true,
false,
false
]
}, },
"hash": "dff8b986c3dde27b8121775e48a58564fa346b038866699210a63f8a33b03f0b" "hash": "dff8b986c3dde27b8121775e48a58564fa346b038866699210a63f8a33b03f0b"
} }

View file

@ -12,9 +12,7 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [ "nullable": [false]
false
]
}, },
"hash": "e05620f420f8c1022971eeb66a803323a8cf258cbebb2834e3f7cf8f812fa646" "hash": "e05620f420f8c1022971eeb66a803323a8cf258cbebb2834e3f7cf8f812fa646"
} }

View file

@ -12,9 +12,7 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [ "nullable": [false]
false
]
}, },
"hash": "e9d422daf774d099fcbde6c4cda35821da948bd86cc57798b4d8375baf0b51ae" "hash": "e9d422daf774d099fcbde6c4cda35821da948bd86cc57798b4d8375baf0b51ae"
} }

View file

@ -72,6 +72,7 @@ swap-env = { path = "../swap-env" }
swap-feed = { path = "../swap-feed" } swap-feed = { path = "../swap-feed" }
swap-fs = { path = "../swap-fs" } swap-fs = { path = "../swap-fs" }
swap-serde = { path = "../swap-serde" } swap-serde = { path = "../swap-serde" }
throttle = { path = "../throttle" }
tauri = { version = "2.0", features = ["config-json5"], optional = true, default-features = false } tauri = { version = "2.0", features = ["config-json5"], optional = true, default-features = false }
thiserror = { workspace = true } thiserror = { workspace = true }
time = "0.3" time = "0.3"

View file

@ -1,7 +1,4 @@
use swap_env::config::GetDefaults;
use crate::bitcoin::{bitcoin_address, Amount}; use crate::bitcoin::{bitcoin_address, Amount};
use swap_env::env;
use swap_env::env::GetConfig;
use anyhow::Result; use anyhow::Result;
use bitcoin::address::NetworkUnchecked; use bitcoin::address::NetworkUnchecked;
use bitcoin::Address; use bitcoin::Address;
@ -9,6 +6,9 @@ use serde::Serialize;
use std::ffi::OsString; use std::ffi::OsString;
use std::path::PathBuf; use std::path::PathBuf;
use structopt::StructOpt; use structopt::StructOpt;
use swap_env::config::GetDefaults;
use swap_env::env;
use swap_env::env::GetConfig;
use uuid::Uuid; use uuid::Uuid;
pub fn parse_args<I, T>(raw_args: I) -> Result<Arguments> pub fn parse_args<I, T>(raw_args: I) -> Result<Arguments>
@ -402,7 +402,9 @@ mod tests {
#[test] #[test]
fn ensure_start_command_mapping_mainnet() { fn ensure_start_command_mapping_mainnet() {
let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults().unwrap().config_path; let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults()
.unwrap()
.config_path;
let mainnet_env_config = env::Mainnet::get_config(); let mainnet_env_config = env::Mainnet::get_config();
let raw_ars = vec![BINARY_NAME, "start"]; let raw_ars = vec![BINARY_NAME, "start"];
@ -420,7 +422,9 @@ mod tests {
#[test] #[test]
fn ensure_history_command_mapping_mainnet() { fn ensure_history_command_mapping_mainnet() {
let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults().unwrap().config_path; let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults()
.unwrap()
.config_path;
let mainnet_env_config = env::Mainnet::get_config(); let mainnet_env_config = env::Mainnet::get_config();
let raw_ars = vec![BINARY_NAME, "history"]; let raw_ars = vec![BINARY_NAME, "history"];
@ -440,7 +444,9 @@ mod tests {
#[test] #[test]
fn ensure_balance_command_mapping_mainnet() { fn ensure_balance_command_mapping_mainnet() {
let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults().unwrap().config_path; let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults()
.unwrap()
.config_path;
let mainnet_env_config = env::Mainnet::get_config(); let mainnet_env_config = env::Mainnet::get_config();
let raw_ars = vec![BINARY_NAME, "balance"]; let raw_ars = vec![BINARY_NAME, "balance"];
@ -458,7 +464,9 @@ mod tests {
#[test] #[test]
fn ensure_withdraw_command_mapping_mainnet() { fn ensure_withdraw_command_mapping_mainnet() {
let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults().unwrap().config_path; let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults()
.unwrap()
.config_path;
let mainnet_env_config = env::Mainnet::get_config(); let mainnet_env_config = env::Mainnet::get_config();
let raw_ars = vec![ let raw_ars = vec![
BINARY_NAME, BINARY_NAME,
@ -484,7 +492,9 @@ mod tests {
#[test] #[test]
fn ensure_cancel_command_mapping_mainnet() { fn ensure_cancel_command_mapping_mainnet() {
let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults().unwrap().config_path; let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults()
.unwrap()
.config_path;
let mainnet_env_config = env::Mainnet::get_config(); let mainnet_env_config = env::Mainnet::get_config();
let raw_ars = vec![ let raw_ars = vec![
@ -510,7 +520,9 @@ mod tests {
#[test] #[test]
fn ensure_refund_command_mappin_mainnet() { fn ensure_refund_command_mappin_mainnet() {
let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults().unwrap().config_path; let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults()
.unwrap()
.config_path;
let mainnet_env_config = env::Mainnet::get_config(); let mainnet_env_config = env::Mainnet::get_config();
let raw_ars = vec![ let raw_ars = vec![
@ -536,7 +548,9 @@ mod tests {
#[test] #[test]
fn ensure_punish_command_mapping_mainnet() { fn ensure_punish_command_mapping_mainnet() {
let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults().unwrap().config_path; let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults()
.unwrap()
.config_path;
let mainnet_env_config = env::Mainnet::get_config(); let mainnet_env_config = env::Mainnet::get_config();
let raw_ars = vec![ let raw_ars = vec![
@ -562,7 +576,9 @@ mod tests {
#[test] #[test]
fn ensure_safely_abort_command_mapping_mainnet() { fn ensure_safely_abort_command_mapping_mainnet() {
let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults().unwrap().config_path; let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults()
.unwrap()
.config_path;
let mainnet_env_config = env::Mainnet::get_config(); let mainnet_env_config = env::Mainnet::get_config();
let raw_ars = vec![ let raw_ars = vec![
@ -588,7 +604,9 @@ mod tests {
#[test] #[test]
fn ensure_start_command_mapping_for_testnet() { fn ensure_start_command_mapping_for_testnet() {
let default_testnet_conf_path = env::Testnet::get_config_file_defaults().unwrap().config_path; let default_testnet_conf_path = env::Testnet::get_config_file_defaults()
.unwrap()
.config_path;
let testnet_env_config = env::Testnet::get_config(); let testnet_env_config = env::Testnet::get_config();
let raw_ars = vec![BINARY_NAME, "--testnet", "start"]; let raw_ars = vec![BINARY_NAME, "--testnet", "start"];
@ -606,7 +624,9 @@ mod tests {
#[test] #[test]
fn ensure_history_command_mapping_testnet() { fn ensure_history_command_mapping_testnet() {
let default_testnet_conf_path = env::Testnet::get_config_file_defaults().unwrap().config_path; let default_testnet_conf_path = env::Testnet::get_config_file_defaults()
.unwrap()
.config_path;
let testnet_env_config = env::Testnet::get_config(); let testnet_env_config = env::Testnet::get_config();
let raw_ars = vec![BINARY_NAME, "--testnet", "history"]; let raw_ars = vec![BINARY_NAME, "--testnet", "history"];
@ -626,7 +646,9 @@ mod tests {
#[test] #[test]
fn ensure_balance_command_mapping_testnet() { fn ensure_balance_command_mapping_testnet() {
let default_testnet_conf_path = env::Testnet::get_config_file_defaults().unwrap().config_path; let default_testnet_conf_path = env::Testnet::get_config_file_defaults()
.unwrap()
.config_path;
let testnet_env_config = env::Testnet::get_config(); let testnet_env_config = env::Testnet::get_config();
let raw_ars = vec![BINARY_NAME, "--testnet", "balance"]; let raw_ars = vec![BINARY_NAME, "--testnet", "balance"];
@ -644,7 +666,9 @@ mod tests {
#[test] #[test]
fn ensure_export_monero_command_mapping_testnet() { fn ensure_export_monero_command_mapping_testnet() {
let default_testnet_conf_path = env::Testnet::get_config_file_defaults().unwrap().config_path; let default_testnet_conf_path = env::Testnet::get_config_file_defaults()
.unwrap()
.config_path;
let testnet_env_config = env::Testnet::get_config(); let testnet_env_config = env::Testnet::get_config();
let raw_ars = vec![BINARY_NAME, "--testnet", "export-monero-wallet"]; let raw_ars = vec![BINARY_NAME, "--testnet", "export-monero-wallet"];
@ -663,7 +687,9 @@ mod tests {
#[test] #[test]
fn ensure_withdraw_command_mapping_testnet() { fn ensure_withdraw_command_mapping_testnet() {
let default_testnet_conf_path = env::Testnet::get_config_file_defaults().unwrap().config_path; let default_testnet_conf_path = env::Testnet::get_config_file_defaults()
.unwrap()
.config_path;
let testnet_env_config = env::Testnet::get_config(); let testnet_env_config = env::Testnet::get_config();
let raw_ars = vec![ let raw_ars = vec![
@ -690,7 +716,9 @@ mod tests {
} }
#[test] #[test]
fn ensure_cancel_command_mapping_testnet() { fn ensure_cancel_command_mapping_testnet() {
let default_testnet_conf_path = env::Testnet::get_config_file_defaults().unwrap().config_path; let default_testnet_conf_path = env::Testnet::get_config_file_defaults()
.unwrap()
.config_path;
let testnet_env_config = env::Testnet::get_config(); let testnet_env_config = env::Testnet::get_config();
let raw_ars = vec![ let raw_ars = vec![
@ -717,7 +745,9 @@ mod tests {
#[test] #[test]
fn ensure_refund_command_mapping_testnet() { fn ensure_refund_command_mapping_testnet() {
let default_testnet_conf_path = env::Testnet::get_config_file_defaults().unwrap().config_path; let default_testnet_conf_path = env::Testnet::get_config_file_defaults()
.unwrap()
.config_path;
let testnet_env_config = env::Testnet::get_config(); let testnet_env_config = env::Testnet::get_config();
let raw_ars = vec![ let raw_ars = vec![
@ -744,7 +774,9 @@ mod tests {
#[test] #[test]
fn ensure_punish_command_mapping_testnet() { fn ensure_punish_command_mapping_testnet() {
let default_testnet_conf_path = env::Testnet::get_config_file_defaults().unwrap().config_path; let default_testnet_conf_path = env::Testnet::get_config_file_defaults()
.unwrap()
.config_path;
let testnet_env_config = env::Testnet::get_config(); let testnet_env_config = env::Testnet::get_config();
let raw_ars = vec![ let raw_ars = vec![
@ -771,7 +803,9 @@ mod tests {
#[test] #[test]
fn ensure_safely_abort_command_mapping_testnet() { fn ensure_safely_abort_command_mapping_testnet() {
let default_testnet_conf_path = env::Testnet::get_config_file_defaults().unwrap().config_path; let default_testnet_conf_path = env::Testnet::get_config_file_defaults()
.unwrap()
.config_path;
let testnet_env_config = env::Testnet::get_config(); let testnet_env_config = env::Testnet::get_config();
let raw_ars = vec![ let raw_ars = vec![
@ -798,7 +832,9 @@ mod tests {
#[test] #[test]
fn ensure_disable_timestamp_mapping() { fn ensure_disable_timestamp_mapping() {
let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults().unwrap().config_path; let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults()
.unwrap()
.config_path;
let mainnet_env_config = env::Mainnet::get_config(); let mainnet_env_config = env::Mainnet::get_config();
let raw_ars = vec![BINARY_NAME, "--disable-timestamp", "start"]; let raw_ars = vec![BINARY_NAME, "--disable-timestamp", "start"];
@ -816,7 +852,9 @@ mod tests {
#[test] #[test]
fn ensure_trace_mapping() { fn ensure_trace_mapping() {
let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults().unwrap().config_path; let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults()
.unwrap()
.config_path;
let mainnet_env_config = env::Mainnet::get_config(); let mainnet_env_config = env::Mainnet::get_config();
let raw_ars = vec![BINARY_NAME, "--trace", "start"]; let raw_ars = vec![BINARY_NAME, "--trace", "start"];

View file

@ -24,9 +24,6 @@ use std::sync::Arc;
use structopt::clap; use structopt::clap;
use structopt::clap::ErrorKind; use structopt::clap::ErrorKind;
use swap::asb::command::{parse_args, Arguments, Command}; use swap::asb::command::{parse_args, Arguments, Command};
use swap_env::config::{
initial_setup, query_user_for_initial_config, read_config, Config, ConfigNotInitialized,
};
use swap::asb::{cancel, punish, redeem, refund, safely_abort, EventLoop, Finality, KrakenRate}; use swap::asb::{cancel, punish, redeem, refund, safely_abort, EventLoop, Finality, KrakenRate};
use swap::common::tor::init_tor_client; use swap::common::tor::init_tor_client;
use swap::common::tracing_util::Format; use swap::common::tracing_util::Format;
@ -40,6 +37,9 @@ use swap::protocol::{Database, State};
use swap::seed::Seed; use swap::seed::Seed;
use swap::{bitcoin, monero}; use swap::{bitcoin, monero};
use swap_feed; use swap_feed;
use swap_env::config::{
initial_setup, query_user_for_initial_config, read_config, Config, ConfigNotInitialized,
};
use tracing_subscriber::filter::LevelFilter; use tracing_subscriber::filter::LevelFilter;
use uuid::Uuid; use uuid::Uuid;
@ -133,7 +133,7 @@ pub async fn main() -> Result<()> {
)); ));
} }
let seed = Seed::from_file_or_generate(&config.data.dir, None) let seed = Seed::from_file_or_generate(&config.data.dir)
.await .await
.expect("Could not retrieve/initialize seed"); .expect("Could not retrieve/initialize seed");
@ -431,7 +431,7 @@ pub async fn main() -> Result<()> {
let monero_wallet = init_monero_wallet(&config, env_config).await?; let monero_wallet = init_monero_wallet(&config, env_config).await?;
let main_wallet = monero_wallet.main_wallet().await; let main_wallet = monero_wallet.main_wallet().await;
let seed = main_wallet.seed().await; let seed = main_wallet.seed().await?;
let creation_height = main_wallet.creation_height().await; let creation_height = main_wallet.creation_height().await;
println!("Seed : {seed}"); println!("Seed : {seed}");
@ -474,7 +474,9 @@ async fn init_bitcoin_wallet(
if sync { if sync {
wallet.sync().await?; wallet.sync().await?;
} else { } else {
tracing::info!("Skipping Bitcoin wallet sync because we are only using it for receiving funds"); tracing::info!(
"Skipping Bitcoin wallet sync because we are only using it for receiving funds"
);
} }
Ok(wallet) Ok(wallet)
@ -528,6 +530,7 @@ async fn init_monero_wallet(
env_config.monero_network, env_config.monero_network,
false, false,
None, None,
None,
) )
.await .await
.context("Failed to initialize Monero wallets")?; .context("Failed to initialize Monero wallets")?;

View file

@ -481,7 +481,6 @@ pub struct NotThreeWitnesses(usize);
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use swap_env::env::{GetConfig, Regtest};
use crate::monero::TransferProof; use crate::monero::TransferProof;
use crate::protocol::{alice, bob}; use crate::protocol::{alice, bob};
use bitcoin::secp256k1; use bitcoin::secp256k1;
@ -490,6 +489,7 @@ mod tests {
use monero::PrivateKey; use monero::PrivateKey;
use rand::rngs::OsRng; use rand::rngs::OsRng;
use std::matches; use std::matches;
use swap_env::env::{GetConfig, Regtest};
use uuid::Uuid; use uuid::Uuid;
#[test] #[test]

View file

@ -1,13 +1,11 @@
pub mod request; pub mod request;
pub mod tauri_bindings; pub mod tauri_bindings;
use crate::cli::api::tauri_bindings::SeedChoice;
use crate::cli::command::{Bitcoin, Monero}; use crate::cli::command::{Bitcoin, Monero};
use crate::common::tor::init_tor_client; use crate::common::tor::init_tor_client;
use crate::common::tracing_util::Format; use crate::common::tracing_util::Format;
use crate::database::{open_db, AccessMode}; use crate::database::{open_db, AccessMode};
use crate::monero::Wallets;
use swap_env::env::{Config as EnvConfig, GetConfig, Mainnet, Testnet};
use swap_fs::system_data_dir;
use crate::network::rendezvous::XmrBtcNamespace; use crate::network::rendezvous::XmrBtcNamespace;
use crate::protocol::Database; use crate::protocol::Database;
use crate::seed::Seed; use crate::seed::Seed;
@ -19,6 +17,8 @@ use std::fmt;
use std::future::Future; use std::future::Future;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Once}; use std::sync::{Arc, Once};
use swap_env::env::{Config as EnvConfig, GetConfig, Mainnet, Testnet};
use swap_fs::system_data_dir;
use tauri_bindings::{ use tauri_bindings::{
MoneroNodeConfig, TauriBackgroundProgress, TauriContextStatusEvent, TauriEmitter, TauriHandle, MoneroNodeConfig, TauriBackgroundProgress, TauriContextStatusEvent, TauriEmitter, TauriHandle,
}; };
@ -40,6 +40,7 @@ pub struct Config {
seed: Option<Seed>, seed: Option<Seed>,
debug: bool, debug: bool,
json: bool, json: bool,
log_dir: PathBuf,
data_dir: PathBuf, data_dir: PathBuf,
is_testnet: bool, is_testnet: bool,
} }
@ -281,7 +282,12 @@ impl ContextBuilder {
/// Takes the builder, initializes the context by initializing the wallets and other components and returns the Context. /// Takes the builder, initializes the context by initializing the wallets and other components and returns the Context.
pub async fn build(self) -> Result<Context> { pub async fn build(self) -> Result<Context> {
let data_dir = &data::data_dir_from(self.data, self.is_testnet)?; // This is the data directory for the eigenwallet (wallet files)
let eigenwallet_data_dir = &eigenwallet_data::new(self.is_testnet)?;
let base_data_dir = &data::data_dir_from(self.data, self.is_testnet)?;
let log_dir = base_data_dir.join("logs");
let env_config = env_config_from(self.is_testnet);
// Initialize logging // Initialize logging
let format = if self.json { Format::Json } else { Format::Raw }; let format = if self.json { Format::Json } else { Format::Raw };
@ -295,7 +301,7 @@ impl ContextBuilder {
let _ = common::tracing_util::init( let _ = common::tracing_util::init(
level_filter, level_filter,
format, format,
data_dir.join("logs"), log_dir.clone(),
self.tauri_handle.clone(), self.tauri_handle.clone(),
false, false,
); );
@ -308,11 +314,97 @@ impl ContextBuilder {
); );
}); });
// These are needed for everything else, and are blocking calls // Start the rpc pool for the monero wallet
let env_config = env_config_from(self.is_testnet); let (server_info, mut status_receiver, pool_handle) =
let seed = &Seed::from_file_or_generate(data_dir.as_path(), self.tauri_handle.clone()) monero_rpc_pool::start_server_with_random_port(
monero_rpc_pool::config::Config::new_random_port(
"127.0.0.1".to_string(),
base_data_dir.join("monero-rpc-pool"),
),
match self.is_testnet {
true => crate::monero::Network::Stagenet,
false => crate::monero::Network::Mainnet,
},
)
.await?;
// Listen for pool status updates and forward them to frontend
let pool_tauri_handle = self.tauri_handle.clone();
tokio::spawn(async move {
while let Ok(status) = status_receiver.recv().await {
pool_tauri_handle.emit_pool_status_update(status);
}
});
// Determine the monero node address to use
let (monero_node_address, monero_rpc_pool_handle) = match &self.monero_config {
Some(MoneroNodeConfig::Pool) => {
let rpc_url = server_info.into();
(rpc_url, Some(Arc::new(pool_handle)))
}
Some(MoneroNodeConfig::SingleNode { url }) => (url.clone(), None),
None => {
// Default to pool if no monero config is provided
let rpc_url = server_info.into();
(rpc_url, Some(Arc::new(pool_handle)))
}
};
// Create a daemon struct for the monero wallet based on the node address
let daemon = monero_sys::Daemon {
address: monero_node_address,
ssl: false,
};
// Initialize wallet database for tracking recent wallets
let wallet_database = monero_sys::Database::new(eigenwallet_data_dir.clone())
.await .await
.context("Failed to read seed in file")?; .context("Failed to initialize wallet database")?;
// Prompt the user to open/create a Monero wallet
let (wallet, seed) = request_and_open_monero_wallet(
self.tauri_handle.clone(),
eigenwallet_data_dir,
base_data_dir,
env_config,
&daemon,
&wallet_database,
)
.await?;
let primary_address = wallet.main_address().await;
// Derive data directory from primary address
let data_dir = base_data_dir
.join("identities")
.join(primary_address.to_string());
// Ensure the identity directory exists
swap_fs::ensure_directory_exists(&data_dir)
.context("Failed to create identity directory")?;
tracing::info!(
primary_address = %primary_address,
data_dir = %data_dir.display(),
"Using wallet-specific data directory"
);
let wallet_database = Some(Arc::new(wallet_database));
// Create the monero wallet manager
let monero_manager = Some(Arc::new(
monero::Wallets::new_with_existing_wallet(
eigenwallet_data_dir.to_path_buf(),
daemon.clone(),
env_config.monero_network,
false,
self.tauri_handle.clone(),
wallet,
wallet_database,
)
.await
.context("Failed to initialize Monero wallets with existing wallet")?,
));
// Create the data structure we use to manage the swap lock // Create the data structure we use to manage the swap lock
let swap_lock = Arc::new(SwapLock::new()); let swap_lock = Arc::new(SwapLock::new());
@ -350,8 +442,8 @@ impl ContextBuilder {
let wallet = init_bitcoin_wallet( let wallet = init_bitcoin_wallet(
urls, urls,
seed, &seed,
data_dir, &data_dir,
env_config, env_config,
target_block, target_block,
self.tauri_handle.clone(), self.tauri_handle.clone(),
@ -368,68 +460,6 @@ impl ContextBuilder {
} }
}; };
let initialize_monero_wallet = async {
match self.monero_config {
Some(monero_config) => {
let monero_progress_handle = tauri_handle
.new_background_process_with_initial_progress(
TauriBackgroundProgress::OpeningMoneroWallet,
(),
);
// If we are instructed to use a pool, we start it and use it
// Otherwise we use the single node address provided by the user
let (monero_node_address, rpc_pool_handle) = match monero_config {
MoneroNodeConfig::Pool => {
// Start RPC pool and use it
let (server_info, mut status_receiver, pool_handle) =
monero_rpc_pool::start_server_with_random_port(
monero_rpc_pool::config::Config::new_random_port(
"127.0.0.1".to_string(),
data_dir.join("monero-rpc-pool"),
),
match self.is_testnet {
true => crate::monero::Network::Stagenet,
false => crate::monero::Network::Mainnet,
},
)
.await?;
let rpc_url =
format!("http://{}:{}", server_info.host, server_info.port);
tracing::info!("Monero RPC Pool started on {}", rpc_url);
// Start listening for pool status updates and forward them to frontend
if let Some(ref handle) = self.tauri_handle {
let pool_tauri_handle = handle.clone();
tokio::spawn(async move {
while let Ok(status) = status_receiver.recv().await {
pool_tauri_handle.emit_pool_status_update(status);
}
});
}
(rpc_url, Some(Arc::new(pool_handle)))
}
MoneroNodeConfig::SingleNode { url } => (url, None),
};
let wallets = init_monero_wallet(
data_dir.as_path(),
monero_node_address,
env_config,
tauri_handle.clone(),
)
.await?;
monero_progress_handle.finish();
Ok((Some(wallets), rpc_pool_handle))
}
None => Ok((None, None)),
}
};
let initialize_tor_client = async { let initialize_tor_client = async {
// Don't init a tor client unless we should use it. // Don't init a tor client unless we should use it.
if !self.tor { if !self.tor {
@ -437,7 +467,7 @@ impl ContextBuilder {
return Ok(None); return Ok(None);
} }
let maybe_tor_client = init_tor_client(data_dir, tauri_handle.clone()) let maybe_tor_client = init_tor_client(&data_dir, tauri_handle.clone())
.await .await
.inspect_err(|err| { .inspect_err(|err| {
tracing::warn!(%err, "Failed to create Tor client. We will continue without Tor"); tracing::warn!(%err, "Failed to create Tor client. We will continue without Tor");
@ -447,11 +477,8 @@ impl ContextBuilder {
Ok(maybe_tor_client) Ok(maybe_tor_client)
}; };
let (bitcoin_wallet, (monero_manager, monero_rpc_pool_handle), tor) = tokio::try_join!( let (bitcoin_wallet, tor) =
initialize_bitcoin_wallet, tokio::try_join!(initialize_bitcoin_wallet, initialize_tor_client,)?;
initialize_monero_wallet,
initialize_tor_client,
)?;
// If we have a bitcoin wallet and a tauri handle, we start a background task // If we have a bitcoin wallet and a tauri handle, we start a background task
if let Some(wallet) = bitcoin_wallet.clone() { if let Some(wallet) = bitcoin_wallet.clone() {
@ -466,8 +493,6 @@ impl ContextBuilder {
} }
} }
tauri_handle.emit_context_init_progress_event(TauriContextStatusEvent::Available);
let context = Context { let context = Context {
db, db,
bitcoin_wallet, bitcoin_wallet,
@ -480,14 +505,17 @@ impl ContextBuilder {
json: self.json, json: self.json,
is_testnet: self.is_testnet, is_testnet: self.is_testnet,
data_dir: data_dir.clone(), data_dir: data_dir.clone(),
log_dir: log_dir.clone(),
}, },
swap_lock, swap_lock,
tasks, tasks,
tauri_handle: self.tauri_handle, tauri_handle: self.tauri_handle,
tor_client: tor, tor_client: tor,
monero_rpc_pool_handle, monero_rpc_pool_handle,
}; };
tauri_handle.emit_context_init_progress_event(TauriContextStatusEvent::Available);
Ok(context) Ok(context)
} }
} }
@ -525,6 +553,15 @@ impl Context {
pub fn cleanup(&self) -> Result<()> { pub fn cleanup(&self) -> Result<()> {
// TODO: close all monero wallets // TODO: close all monero wallets
// call store(..) on all wallets
let monero_manager = self.monero_manager.clone();
tokio::spawn(async move {
if let Some(monero_manager) = monero_manager {
let wallet = monero_manager.main_wallet().await;
wallet.store(None).await;
}
});
Ok(()) Ok(())
} }
@ -575,56 +612,227 @@ async fn init_bitcoin_wallet(
Ok(wallet) Ok(wallet)
} }
async fn init_monero_wallet( async fn request_and_open_monero_wallet_legacy(
data_dir: &Path, data_dir: &PathBuf,
monero_daemon_address: String,
env_config: EnvConfig, env_config: EnvConfig,
tauri_handle: Option<TauriHandle>, daemon: &monero_sys::Daemon,
) -> Result<Arc<Wallets>> { ) -> Result<monero_sys::WalletHandle, Error> {
let network = env_config.monero_network; let wallet_path = data_dir.join("swap-tool-blockchain-monitoring-wallet");
let wallet_dir = data_dir.join("monero").join("monero-data");
let daemon = monero_sys::Daemon { let wallet = monero::Wallet::open_or_create(
address: monero_daemon_address, wallet_path.display().to_string(),
ssl: false, daemon.clone(),
}; env_config.monero_network,
true,
// This is the name of a wallet we only use for blockchain monitoring
const DEFAULT_WALLET: &str = "swap-tool-blockchain-monitoring-wallet";
// Remove the monitoring wallet if it exists
// It doesn't contain any coins
// Deleting it ensures we never have issues at startup
// And we reset the restore height
let wallet_path = wallet_dir.join(DEFAULT_WALLET);
if wallet_path.exists() {
tracing::debug!(
wallet_path = %wallet_path.display(),
"Removing monitoring wallet"
);
let _ = tokio::fs::remove_file(&wallet_path).await;
}
let keys_path = wallet_path.with_extension("keys");
if keys_path.exists() {
tracing::debug!(
keys_path = %keys_path.display(),
"Removing monitoring wallet keys"
);
let _ = tokio::fs::remove_file(keys_path).await;
}
let wallets = monero::Wallets::new(
wallet_dir,
DEFAULT_WALLET.to_string(),
daemon,
network,
false,
tauri_handle,
) )
.await .await
.context("Failed to initialize Monero wallets")?; .context("Failed to create wallet")?;
Ok(Arc::new(wallets)) Ok(wallet)
}
/// Opens or creates a Monero wallet after asking the user via the Tauri UI.
///
/// The user can:
/// - Create a new wallet with a random seed.
/// - Recover a wallet from a given seed phrase.
/// - Open an existing wallet file (with password verification).
///
/// Errors if the user aborts, provides an incorrect password, or the wallet
/// fails to open/create.
async fn request_and_open_monero_wallet(
tauri_handle: Option<TauriHandle>,
eigenwallet_data_dir: &PathBuf,
legacy_data_dir: &PathBuf,
env_config: EnvConfig,
daemon: &monero_sys::Daemon,
wallet_database: &monero_sys::Database,
) -> Result<(monero_sys::WalletHandle, Seed), Error> {
let eigenwallet_wallets_dir = eigenwallet_data_dir.join("wallets");
let wallet = match tauri_handle {
Some(tauri_handle) => {
// Get recent wallets from database
let recent_wallets: Vec<String> = wallet_database
.get_recent_wallets(5)
.await
.unwrap_or_default()
.into_iter()
.map(|w| w.wallet_path)
.collect();
// This loop continually requests the user to select a wallet file
// It then requests the user to provide a password.
// It repeats until the user provides a valid password or rejects the password request
// When the user rejects the password request, we prompt him to select a wallet again
loop {
let seed_choice = tauri_handle
.request_seed_selection_with_recent_wallets(recent_wallets.clone())
.await?;
let _monero_progress_handle = tauri_handle
.new_background_process_with_initial_progress(
TauriBackgroundProgress::OpeningMoneroWallet,
(),
);
fn new_wallet_path(eigenwallet_wallets_dir: &PathBuf) -> Result<PathBuf> {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let wallet_path = eigenwallet_wallets_dir.join(format!("wallet_{}", timestamp));
if let Some(parent) = wallet_path.parent() {
swap_fs::ensure_directory_exists(parent)
.context("Failed to create wallet directory")?;
}
Ok(wallet_path)
}
let wallet = match seed_choice {
SeedChoice::RandomSeed => {
// Create wallet with Unix timestamp as name
let wallet_path = new_wallet_path(&eigenwallet_wallets_dir)
.context("Failed to determine path for new wallet")?;
monero::Wallet::open_or_create(
wallet_path.display().to_string(),
daemon.clone(),
env_config.monero_network,
true,
)
.await
.context("Failed to create wallet from random seed")?
}
SeedChoice::FromSeed { seed: mnemonic } => {
// Create wallet from provided seed
let wallet_path = new_wallet_path(&eigenwallet_wallets_dir)
.context("Failed to determine path for new wallet")?;
monero::Wallet::open_or_create_from_seed(
wallet_path.display().to_string(),
mnemonic,
env_config.monero_network,
0,
true,
daemon.clone(),
)
.await
.context("Failed to create wallet from provided seed")?
}
SeedChoice::FromWalletPath { wallet_path } => {
// Helper function to verify password
let verify_password = |password: String| -> Result<bool> {
monero_sys::WalletHandle::verify_wallet_password(
wallet_path.clone(),
password,
)
.map_err(|e| anyhow::anyhow!("Failed to verify wallet password: {}", e))
};
// Request and verify password before opening wallet
let wallet_password: Option<String> = {
const WALLET_EMPTY_PASSWORD: &str = "";
// First try empty password
if verify_password(WALLET_EMPTY_PASSWORD.to_string())? {
Some(WALLET_EMPTY_PASSWORD.to_string())
} else {
// If empty password fails, ask user for password
loop {
// Request password from user
let password = tauri_handle
.request_password(wallet_path.clone())
.await
.inspect_err(|e| {
tracing::error!(
"Failed to get password from user: {}",
e
);
})
.ok();
// If the user rejects the password request (presses cancel)
// We prompt him to select a wallet again
let password = match password {
Some(password) => password,
None => break None,
};
// Verify the password using the helper function
match verify_password(password.clone()) {
Ok(true) => {
break Some(password);
}
Ok(false) => {
// Continue loop to request password again
continue;
}
Err(e) => {
return Err(e);
}
}
}
}
};
let password = match wallet_password {
Some(password) => password,
// None means the user rejected the password request
// We prompt him to select a wallet again
None => {
continue;
}
};
// Open existing wallet with verified password
monero::Wallet::open_or_create_with_password(
wallet_path.clone(),
password,
daemon.clone(),
env_config.monero_network,
true,
)
.await
.context("Failed to open wallet from provided path")?
}
SeedChoice::Legacy => {
let wallet = request_and_open_monero_wallet_legacy(legacy_data_dir, env_config, daemon).await?;
let seed = Seed::from_file_or_generate(legacy_data_dir)
.await
.context("Failed to extract seed from wallet")?;
break (wallet, seed);
}
};
// Extract seed from the wallet
tracing::info!("Extracting seed from wallet directory: {}", legacy_data_dir.display());
let seed = Seed::from_monero_wallet(&wallet)
.await
.context("Failed to extract seed from wallet")?;
break (wallet, seed);
}
}
// If we don't have a tauri handle, we use the seed.pem file
// This is used for the CLI to monitor the blockchain
None => {
let wallet = request_and_open_monero_wallet_legacy(legacy_data_dir, env_config, daemon).await?;
let seed = Seed::from_file_or_generate(legacy_data_dir)
.await
.context("Failed to extract seed from wallet")?;
(wallet, seed)
}
};
Ok(wallet)
} }
pub mod data { pub mod data {
@ -646,6 +854,16 @@ pub mod data {
} }
} }
pub mod eigenwallet_data {
use swap_fs::system_data_dir_eigenwallet;
use super::*;
pub fn new(testnet: bool) -> Result<PathBuf> {
Ok(system_data_dir_eigenwallet(testnet)?)
}
}
fn env_config_from(testnet: bool) -> EnvConfig { fn env_config_from(testnet: bool) -> EnvConfig {
if testnet { if testnet {
Testnet::get_config() Testnet::get_config()
@ -657,6 +875,7 @@ fn env_config_from(testnet: bool) -> EnvConfig {
impl Config { impl Config {
pub fn for_harness(seed: Seed, env_config: EnvConfig) -> Self { pub fn for_harness(seed: Seed, env_config: EnvConfig) -> Self {
let data_dir = data::data_dir_from(None, false).expect("Could not find data directory"); let data_dir = data::data_dir_from(None, false).expect("Could not find data directory");
let log_dir = data_dir.join("logs"); // not used in production
Self { Self {
namespace: XmrBtcNamespace::from_is_testnet(false), namespace: XmrBtcNamespace::from_is_testnet(false),
@ -666,6 +885,7 @@ impl Config {
json: false, json: false,
is_testnet: false, is_testnet: false,
data_dir, data_dir,
log_dir,
} }
} }
} }
@ -707,7 +927,8 @@ pub mod api_test {
json: bool, json: bool,
) -> Self { ) -> Self {
let data_dir = data::data_dir_from(data_dir, is_testnet).unwrap(); let data_dir = data::data_dir_from(data_dir, is_testnet).unwrap();
let seed = Seed::from_file_or_generate(data_dir.as_path(), None) let log_dir = data_dir.clone().join("logs");
let seed = Seed::from_file_or_generate(data_dir.as_path())
.await .await
.unwrap(); .unwrap();
let env_config = env_config_from(is_testnet); let env_config = env_config_from(is_testnet);
@ -720,6 +941,7 @@ pub mod api_test {
json, json,
is_testnet, is_testnet,
data_dir, data_dir,
log_dir,
} }
} }
} }

View file

@ -1,6 +1,9 @@
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::{SelectMakerDetails, TauriEmitter, TauriSwapProgressEvent}; use crate::cli::api::tauri_bindings::{
ApprovalRequestType, SelectMakerDetails, SendMoneroDetails, TauriEmitter,
TauriSwapProgressEvent,
};
use crate::cli::api::Context; use crate::cli::api::Context;
use crate::cli::list_sellers::{list_sellers_init, QuoteWithAddress, UnreachableSeller}; use crate::cli::list_sellers::{list_sellers_init, QuoteWithAddress, UnreachableSeller};
use crate::cli::{list_sellers as list_sellers_impl, EventLoop, SellerStatus}; use crate::cli::{list_sellers as list_sellers_impl, EventLoop, SellerStatus};
@ -30,6 +33,7 @@ use serde_json::json;
use std::convert::TryInto; use std::convert::TryInto;
use std::future::Future; use std::future::Future;
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use thiserror::Error; use thiserror::Error;
@ -417,7 +421,7 @@ impl Request for GetLogsArgs {
type Response = GetLogsResponse; type Response = GetLogsResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> { async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let dir = self.logs_dir.unwrap_or(ctx.config.data_dir.join("logs")); let dir = self.logs_dir.unwrap_or(ctx.config.log_dir.clone());
let logs = get_logs(dir, self.swap_id, self.redact).await?; let logs = get_logs(dir, self.swap_id, self.redact).await?;
for msg in &logs { for msg in &logs {
@ -451,6 +455,32 @@ impl Request for RedactArgs {
} }
} }
#[typeshare]
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GetRestoreHeightArgs;
#[typeshare]
#[derive(Serialize, Deserialize, Debug)]
pub struct GetRestoreHeightResponse {
#[typeshare(serialized_as = "number")]
pub height: u64,
}
impl Request for GetRestoreHeightArgs {
type Response = GetRestoreHeightResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let wallet = ctx
.monero_manager
.as_ref()
.context("Monero wallet manager not available")?;
let wallet = wallet.main_wallet().await;
let height = wallet.get_restore_height().await?;
Ok(GetRestoreHeightResponse { height })
}
}
#[typeshare] #[typeshare]
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GetMoneroAddressesArgs; pub struct GetMoneroAddressesArgs;
@ -471,6 +501,282 @@ impl Request for GetMoneroAddressesArgs {
} }
} }
#[typeshare]
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GetMoneroHistoryArgs;
#[typeshare]
#[derive(Serialize, Clone, Deserialize, Debug)]
pub struct GetMoneroHistoryResponse {
pub transactions: Vec<monero_sys::TransactionInfo>,
}
impl Request for GetMoneroHistoryArgs {
type Response = GetMoneroHistoryResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let wallet = ctx
.monero_manager
.as_ref()
.context("Monero wallet manager not available")?;
let wallet = wallet.main_wallet().await;
let transactions = wallet.history().await;
Ok(GetMoneroHistoryResponse { transactions })
}
}
#[typeshare]
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GetMoneroMainAddressArgs;
#[typeshare]
#[derive(Serialize, Deserialize, Debug)]
pub struct GetMoneroMainAddressResponse {
#[typeshare(serialized_as = "String")]
pub address: monero::Address,
}
impl Request for GetMoneroMainAddressArgs {
type Response = GetMoneroMainAddressResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let wallet = ctx
.monero_manager
.as_ref()
.context("Monero wallet manager not available")?;
let wallet = wallet.main_wallet().await;
let address = wallet.main_address().await;
Ok(GetMoneroMainAddressResponse { address })
}
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Date {
#[typeshare(serialized_as = "number")]
pub year: u16,
#[typeshare(serialized_as = "number")]
pub month: u8,
#[typeshare(serialized_as = "number")]
pub day: u8,
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type", content = "height")]
pub enum SetRestoreHeightArgs {
#[typeshare(serialized_as = "number")]
Height(u32),
#[typeshare(serialized_as = "object")]
Date(Date),
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug)]
pub struct SetRestoreHeightResponse {
pub success: bool,
}
impl Request for SetRestoreHeightArgs {
type Response = SetRestoreHeightResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let wallet = ctx
.monero_manager
.as_ref()
.context("Monero wallet manager not available")?;
let wallet = wallet.main_wallet().await;
let height = match self {
SetRestoreHeightArgs::Height(height) => height as u64,
SetRestoreHeightArgs::Date(date) => {
let year: u16 = date.year;
let month: u8 = date.month;
let day: u8 = date.day;
// Validate ranges
if month < 1 || month > 12 {
bail!("Month must be between 1 and 12");
}
if day < 1 || day > 31 {
bail!("Day must be between 1 and 31");
}
tracing::info!(
"Getting blockchain height for date: {}-{}-{}",
year,
month,
day
);
let height = wallet
.get_blockchain_height_by_date(year, month, day)
.await
.with_context(|| {
format!(
"Failed to get blockchain height for date {}-{}-{}",
year, month, day
)
})?;
tracing::info!(
"Blockchain height for date {}-{}-{}: {}",
year,
month,
day,
height
);
height
}
};
wallet.set_restore_height(height).await?;
wallet.pause_refresh().await;
wallet.stop().await;
tracing::debug!("Background refresh stopped");
wallet.rescan_blockchain_async().await;
wallet.start_refresh().await;
tracing::info!("Rescanning blockchain from height {} completed", height);
Ok(SetRestoreHeightResponse { success: true })
}
}
// New request type for Monero balance
#[typeshare]
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GetMoneroBalanceArgs;
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GetMoneroBalanceResponse {
#[typeshare(serialized_as = "string")]
pub total_balance: crate::monero::Amount,
#[typeshare(serialized_as = "string")]
pub unlocked_balance: crate::monero::Amount,
}
impl Request for GetMoneroBalanceArgs {
type Response = GetMoneroBalanceResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let wallet_manager = ctx
.monero_manager
.as_ref()
.context("Monero wallet manager not available")?;
let wallet = wallet_manager.main_wallet().await;
let total_balance = wallet.total_balance().await;
let unlocked_balance = wallet.unlocked_balance().await;
Ok(GetMoneroBalanceResponse {
total_balance: crate::monero::Amount::from_piconero(total_balance.as_pico()),
unlocked_balance: crate::monero::Amount::from_piconero(unlocked_balance.as_pico()),
})
}
}
#[typeshare]
#[derive(Debug, Serialize, Deserialize)]
pub struct SendMoneroArgs {
#[typeshare(serialized_as = "String")]
pub address: String,
pub amount: SendMoneroAmount,
}
#[typeshare]
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", content = "amount")]
pub enum SendMoneroAmount {
Sweep,
Specific(crate::monero::Amount),
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug)]
pub struct SendMoneroResponse {
pub tx_hash: String,
pub address: String,
pub amount_sent: crate::monero::Amount,
pub fee: crate::monero::Amount,
}
impl Request for SendMoneroArgs {
type Response = SendMoneroResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let wallet_manager = ctx
.monero_manager
.as_ref()
.context("Monero wallet manager not available")?;
let wallet = wallet_manager.main_wallet().await;
// Parse the address
let address = monero::Address::from_str(&self.address)
.map_err(|e| anyhow::anyhow!("Invalid Monero address: {}", e))?;
let tauri_handle = ctx
.tauri_handle()
.context("Tauri needs to be available to approve transactions")?;
// This is a closure that will be called by the monero-sys library to get approval for the transaction
// It sends an approval request to the frontend and returns true if the user approves the transaction
let approval_callback: Arc<
dyn Fn(
String,
::monero::Amount,
::monero::Amount,
)
-> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>>
+ Send
+ Sync,
> = std::sync::Arc::new(
move |_txid: String, amount: ::monero::Amount, fee: ::monero::Amount| {
let tauri_handle = tauri_handle.clone();
Box::pin(async move {
let details = SendMoneroDetails {
address: address.to_string(),
amount: amount.into(),
fee: fee.into(),
};
tauri_handle
.request_approval::<bool>(
ApprovalRequestType::SendMonero(details),
Some(60 * 5),
)
.await
.unwrap_or(false)
})
},
);
let amount = match self.amount {
SendMoneroAmount::Sweep => None,
SendMoneroAmount::Specific(amount) => Some(amount.into()),
};
// This is the actual call to the monero-sys library to send the transaction
// monero-sys will call the approval callback after it has constructed and signed the transaction
// once the user approves, the transaction is published
let (receipt, amount_sent, fee) = wallet
.transfer_with_approval(&address, amount, approval_callback)
.await?
.context("Transaction was not approved by user")?;
Ok(SendMoneroResponse {
tx_hash: receipt.txid,
address: address.to_string(),
amount_sent: amount_sent.into(),
fee: fee.into(),
})
}
}
#[tracing::instrument(fields(method = "suspend_current_swap"), skip(context))] #[tracing::instrument(fields(method = "suspend_current_swap"), skip(context))]
pub async fn suspend_current_swap(context: Arc<Context>) -> Result<SuspendCurrentSwapResponse> { pub async fn suspend_current_swap(context: Arc<Context>) -> Result<SuspendCurrentSwapResponse> {
let swap_id = context.swap_lock.get_current_swap_id().await; let swap_id = context.swap_lock.get_current_swap_id().await;
@ -1248,23 +1554,6 @@ pub async fn get_current_swap(context: Arc<Context>) -> Result<GetCurrentSwapRes
Ok(GetCurrentSwapResponse { swap_id }) Ok(GetCurrentSwapResponse { swap_id })
} }
pub async fn resolve_approval_request(
resolve_approval: ResolveApprovalArgs,
ctx: Arc<Context>,
) -> Result<ResolveApprovalResponse> {
let request_id = Uuid::parse_str(&resolve_approval.request_id).context("Invalid request ID")?;
if let Some(handle) = ctx.tauri_handle.clone() {
handle
.resolve_approval(request_id, resolve_approval.accept)
.await?;
} else {
bail!("Cannot resolve approval without a Tauri handle");
}
Ok(ResolveApprovalResponse { success: true })
}
pub async fn fetch_quotes_task( pub async fn fetch_quotes_task(
rendezvous_points: Vec<Multiaddr>, rendezvous_points: Vec<Multiaddr>,
namespace: XmrBtcNamespace, namespace: XmrBtcNamespace,
@ -1638,6 +1927,18 @@ pub struct ResolveApprovalResponse {
pub success: bool, pub success: bool,
} }
#[typeshare]
#[derive(Debug, Serialize, Deserialize)]
pub struct RejectApprovalArgs {
pub request_id: String,
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug)]
pub struct RejectApprovalResponse {
pub success: bool,
}
#[typeshare] #[typeshare]
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct CheckSeedArgs { pub struct CheckSeedArgs {
@ -1659,6 +1960,42 @@ impl CheckSeedArgs {
} }
} }
// New request type for Monero sync progress
#[typeshare]
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GetMoneroSyncProgressArgs;
#[typeshare]
#[derive(Serialize, Clone, Deserialize, Debug)]
pub struct GetMoneroSyncProgressResponse {
#[typeshare(serialized_as = "number")]
pub current_block: u64,
#[typeshare(serialized_as = "number")]
pub target_block: u64,
#[typeshare(serialized_as = "number")]
pub progress_percentage: f32,
}
impl Request for GetMoneroSyncProgressArgs {
type Response = GetMoneroSyncProgressResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let wallet_manager = ctx
.monero_manager
.as_ref()
.context("Monero wallet manager not available")?;
let wallet = wallet_manager.main_wallet().await;
let sync_progress = wallet.call(|wallet| wallet.sync_progress()).await;
Ok(GetMoneroSyncProgressResponse {
current_block: sync_progress.current_block,
target_block: sync_progress.target_block,
progress_percentage: sync_progress.percentage(),
})
}
}
#[typeshare] #[typeshare]
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct GetPendingApprovalsResponse { pub struct GetPendingApprovalsResponse {

View file

@ -1,5 +1,8 @@
use super::request::BalanceResponse; use super::request::BalanceResponse;
use crate::bitcoin; use crate::bitcoin;
use crate::cli::api::request::{
GetMoneroBalanceResponse, GetMoneroHistoryResponse, GetMoneroSyncProgressResponse,
};
use crate::cli::list_sellers::QuoteWithAddress; use crate::cli::list_sellers::QuoteWithAddress;
use crate::monero::MoneroAddressPool; use crate::monero::MoneroAddressPool;
use crate::{bitcoin::ExpiredTimelocks, monero, network::quote::BidQuote}; use crate::{bitcoin::ExpiredTimelocks, monero, network::quote::BidQuote};
@ -31,6 +34,16 @@ pub enum TauriEvent {
Approval(ApprovalRequest), Approval(ApprovalRequest),
BackgroundProgress(TauriBackgroundProgressWrapper), BackgroundProgress(TauriBackgroundProgressWrapper),
PoolStatusUpdate(PoolStatus), PoolStatusUpdate(PoolStatus),
MoneroWalletUpdate(MoneroWalletUpdate),
}
#[typeshare]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", content = "content")]
pub enum MoneroWalletUpdate {
BalanceChange(GetMoneroBalanceResponse),
SyncProgress(GetMoneroSyncProgressResponse),
HistoryUpdate(GetMoneroHistoryResponse),
} }
const TAURI_UNIFIED_EVENT_NAME: &str = "tauri-unified-event"; const TAURI_UNIFIED_EVENT_NAME: &str = "tauri-unified-event";
@ -62,12 +75,42 @@ pub struct SelectMakerDetails {
pub maker: QuoteWithAddress, pub maker: QuoteWithAddress,
} }
#[typeshare]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SendMoneroDetails {
/// Destination address for the Monero transfer
#[typeshare(serialized_as = "string")]
pub address: String,
/// Amount to send
#[typeshare(serialized_as = "number")]
pub amount: monero::Amount,
/// Transaction fee
#[typeshare(serialized_as = "number")]
pub fee: monero::Amount,
}
#[typeshare]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PasswordRequestDetails {
/// The wallet file path that requires a password
pub wallet_path: String,
}
#[typeshare] #[typeshare]
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", content = "content")] #[serde(tag = "type", content = "content")]
pub enum SeedChoice { pub enum SeedChoice {
RandomSeed, RandomSeed,
FromSeed { seed: String }, FromSeed { seed: String },
FromWalletPath { wallet_path: String },
Legacy,
}
#[typeshare]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SeedSelectionDetails {
/// List of recently used wallet paths
pub recent_wallets: Vec<String>,
} }
#[typeshare] #[typeshare]
@ -90,8 +133,13 @@ pub enum ApprovalRequestType {
/// Contains available makers and swap details. /// Contains available makers and swap details.
SelectMaker(SelectMakerDetails), SelectMaker(SelectMakerDetails),
/// Request seed selection from user. /// Request seed selection from user.
/// User can choose between random seed or provide their own. /// User can choose between random seed, provide their own, or select wallet file.
SeedSelection, SeedSelection(SeedSelectionDetails),
/// Request approval for publishing a Monero transaction.
SendMonero(SendMoneroDetails),
/// Request password for wallet file.
/// User must provide password to unlock the selected wallet.
PasswordRequest(PasswordRequestDetails),
} }
#[typeshare] #[typeshare]
@ -297,9 +345,9 @@ impl TauriHandle {
) -> Result<()> { ) -> Result<()> {
#[cfg(not(feature = "tauri"))] #[cfg(not(feature = "tauri"))]
{ {
return Err(anyhow!( Err(anyhow!(
"Cannot resolve approval: Tauri feature not enabled." "Cannot resolve approval: Tauri feature not enabled."
)); ))
} }
#[cfg(feature = "tauri")] #[cfg(feature = "tauri")]
@ -323,26 +371,38 @@ impl TauriHandle {
} }
} }
pub async fn get_pending_approvals(&self) -> Result<Vec<ApprovalRequest>> { pub async fn reject_approval(&self, request_id: Uuid) -> Result<()> {
#[cfg(not(feature = "tauri"))] #[cfg(not(feature = "tauri"))]
{ {
return Ok(Vec::new()); Err(anyhow!(
"Cannot reject approval: Tauri feature not enabled."
))
} }
#[cfg(feature = "tauri")] #[cfg(feature = "tauri")]
{ {
let pending_map = self let mut pending_map = self
.0 .0
.pending_approvals .pending_approvals
.lock() .lock()
.map_err(|e| anyhow!("Failed to acquire approval lock: {}", e))?; .map_err(|e| anyhow!("Failed to acquire approval lock: {}", e))?;
if let Some(mut pending) = pending_map.remove(&request_id) {
// Send rejection through oneshot channel
if let Some(responder) = pending.responder.take() {
let _ = responder.send(serde_json::Value::Null);
let approvals: Vec<ApprovalRequest> = pending_map // Emit the rejection event
.values() let mut approval = pending.request.clone();
.map(|pending| pending.request.clone()) approval.request_status = RequestStatus::Rejected;
.collect(); self.emit_approval(approval);
Ok(approvals) Ok(())
} else {
Err(anyhow!("Approval responder was already consumed"))
}
} else {
Err(anyhow!("Approval not found or already handled"))
}
} }
} }
} }
@ -352,7 +412,9 @@ impl Display for ApprovalRequest {
match self.request { match self.request {
ApprovalRequestType::LockBitcoin(..) => write!(f, "LockBitcoin()"), ApprovalRequestType::LockBitcoin(..) => write!(f, "LockBitcoin()"),
ApprovalRequestType::SelectMaker(..) => write!(f, "SelectMaker()"), ApprovalRequestType::SelectMaker(..) => write!(f, "SelectMaker()"),
ApprovalRequestType::SeedSelection => write!(f, "SeedSelection()"), ApprovalRequestType::SeedSelection(_) => write!(f, "SeedSelection()"),
ApprovalRequestType::SendMonero(_) => write!(f, "SendMonero()"),
ApprovalRequestType::PasswordRequest(_) => write!(f, "PasswordRequest()"),
} }
} }
} }
@ -373,6 +435,13 @@ pub trait TauriEmitter {
async fn request_seed_selection(&self) -> Result<SeedChoice>; async fn request_seed_selection(&self) -> Result<SeedChoice>;
async fn request_seed_selection_with_recent_wallets(
&self,
recent_wallets: Vec<String>,
) -> Result<SeedChoice>;
async fn request_password(&self, wallet_path: String) -> Result<String>;
fn emit_tauri_event<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()>; fn emit_tauri_event<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()>;
fn emit_unified_event(&self, event: TauriEvent) { fn emit_unified_event(&self, event: TauriEvent) {
@ -468,7 +537,22 @@ impl TauriEmitter for TauriHandle {
} }
async fn request_seed_selection(&self) -> Result<SeedChoice> { async fn request_seed_selection(&self) -> Result<SeedChoice> {
self.request_approval(ApprovalRequestType::SeedSelection, None) self.request_seed_selection_with_recent_wallets(vec![])
.await
}
async fn request_seed_selection_with_recent_wallets(
&self,
recent_wallets: Vec<String>,
) -> Result<SeedChoice> {
let details = SeedSelectionDetails { recent_wallets };
self.request_approval(ApprovalRequestType::SeedSelection(details), None)
.await
}
async fn request_password(&self, wallet_path: String) -> Result<String> {
let details = PasswordRequestDetails { wallet_path };
self.request_approval(ApprovalRequestType::PasswordRequest(details), None)
.await .await
} }
@ -541,6 +625,27 @@ impl TauriEmitter for Option<TauriHandle> {
} }
} }
async fn request_seed_selection_with_recent_wallets(
&self,
recent_wallets: Vec<String>,
) -> Result<SeedChoice> {
match self {
Some(tauri) => {
tauri
.request_seed_selection_with_recent_wallets(recent_wallets)
.await
}
None => bail!("No Tauri handle available"),
}
}
async fn request_password(&self, wallet_path: String) -> Result<String> {
match self {
Some(tauri) => tauri.request_password(wallet_path).await,
None => bail!("No Tauri handle available"),
}
}
fn new_background_process<T: Clone>( fn new_background_process<T: Clone>(
&self, &self,
component: fn(PendingCompleted<T>) -> TauriBackgroundProgress, component: fn(PendingCompleted<T>) -> TauriBackgroundProgress,
@ -566,7 +671,30 @@ impl TauriEmitter for Option<TauriHandle> {
} }
} }
/// A handle for updating a specific background process's progress impl TauriHandle {
#[cfg(feature = "tauri")]
pub async fn get_pending_approvals(&self) -> Result<Vec<ApprovalRequest>> {
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)
}
#[cfg(not(feature = "tauri"))]
pub async fn get_pending_approvals(&self) -> Result<Vec<ApprovalRequest>> {
Ok(Vec::new())
}
}
/// A handle for updating a specific background progress's progress
/// ///
/// # Examples /// # Examples
/// ///

View file

@ -1,3 +1,4 @@
use crate::bitcoin;
use crate::monero::{Scalar, TransferProof}; use crate::monero::{Scalar, TransferProof};
use crate::network::cooperative_xmr_redeem_after_punish::CooperativeXmrRedeemRejectReason; use crate::network::cooperative_xmr_redeem_after_punish::CooperativeXmrRedeemRejectReason;
use crate::network::quote::BidQuote; use crate::network::quote::BidQuote;
@ -7,8 +8,6 @@ use crate::network::{
cooperative_xmr_redeem_after_punish, encrypted_signature, quote, redial, transfer_proof, cooperative_xmr_redeem_after_punish, encrypted_signature, quote, redial, transfer_proof,
}; };
use crate::protocol::bob::State2; use crate::protocol::bob::State2;
use crate::bitcoin;
use swap_env::env;
use anyhow::{anyhow, Error, Result}; use anyhow::{anyhow, Error, Result};
use libp2p::request_response::{ use libp2p::request_response::{
InboundFailure, InboundRequestId, OutboundFailure, OutboundRequestId, ResponseChannel, InboundFailure, InboundRequestId, OutboundFailure, OutboundRequestId, ResponseChannel,
@ -17,6 +16,7 @@ use libp2p::swarm::NetworkBehaviour;
use libp2p::{identify, identity, ping, PeerId}; use libp2p::{identify, identity, ping, PeerId};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use swap_env::env;
#[derive(Debug)] #[derive(Debug)]
pub enum OutEvent { pub enum OutEvent {

Some files were not shown because too many files have changed in this diff Show more