mirror of
https://github.com/comit-network/xmr-btc-swap.git
synced 2025-11-27 03:06:24 -05:00
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:
parent
eb0dc10489
commit
a7823d7489
118 changed files with 7857 additions and 3456 deletions
115
Cargo.lock
generated
115
Cargo.lock
generated
|
|
@ -1978,16 +1978,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
|
|
@ -6078,6 +6068,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"backoff",
|
||||
"chrono",
|
||||
"cmake",
|
||||
"cxx",
|
||||
"cxx-build",
|
||||
|
|
@ -6347,15 +6338,6 @@ dependencies = [
|
|||
"instant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.46.0"
|
||||
|
|
@ -7839,26 +7821,6 @@ version = "0.6.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "rcgen"
|
||||
version = "0.11.3"
|
||||
|
|
@ -9829,6 +9791,7 @@ dependencies = [
|
|||
"tempfile",
|
||||
"testcontainers",
|
||||
"thiserror 1.0.69",
|
||||
"throttle",
|
||||
"time 0.3.41",
|
||||
"tokio",
|
||||
"tokio-tar",
|
||||
|
|
@ -9980,20 +9943,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "system-configuration"
|
||||
version = "0.6.1"
|
||||
|
|
@ -10630,6 +10579,13 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "throttle"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.9.1"
|
||||
|
|
@ -12412,7 +12368,6 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"swap",
|
||||
"sysinfo",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-cli",
|
||||
|
|
@ -12956,8 +12911,8 @@ dependencies = [
|
|||
"webview2-com-sys",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-implement 0.60.0",
|
||||
"windows-interface 0.59.1",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -13060,16 +13015,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
|
|
@ -13102,26 +13047,14 @@ dependencies = [
|
|||
"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]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
dependencies = [
|
||||
"windows-implement 0.60.0",
|
||||
"windows-interface 0.59.1",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings",
|
||||
|
|
@ -13138,17 +13071,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.0"
|
||||
|
|
@ -13160,17 +13082,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.1"
|
||||
|
|
|
|||
12
Cargo.toml
12
Cargo.toml
|
|
@ -1,6 +1,6 @@
|
|||
[workspace]
|
||||
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]
|
||||
anyhow = "1"
|
||||
|
|
@ -11,17 +11,17 @@ futures = { version = "0.3", default-features = false, features = ["std"] }
|
|||
tracing = { version = "0.1", features = ["attributes"] }
|
||||
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "ansi", "env-filter", "time", "tracing-log", "json"] }
|
||||
bitcoin = { version = "0.32", features = ["rand", "serde"] }
|
||||
hex = "0.4"
|
||||
libp2p = { version = "0.53.2" }
|
||||
monero = { version = "0.12", features = ["serde_support"] }
|
||||
rand = "0.8"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
typeshare = "1.0"
|
||||
thiserror = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json"] }
|
||||
rust_decimal = { version = "1", features = ["serde-float"] }
|
||||
rust_decimal_macros = "1"
|
||||
libp2p = { version = "0.53.2" }
|
||||
thiserror = "1"
|
||||
typeshare = "1.0"
|
||||
url = { version = "2", features = ["serde"] }
|
||||
hex = "0.4"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[patch.crates-io]
|
||||
# patch until new release https://github.com/thomaseizinger/rust-jsonrpc-client/pull/51
|
||||
|
|
|
|||
4
justfile
4
justfile
|
|
@ -26,7 +26,7 @@ test-ffi-address:
|
|||
|
||||
# Start the Tauri app
|
||||
tauri:
|
||||
cd src-tauri && cargo tauri dev --no-watch -- -- --testnet
|
||||
cd src-tauri && cargo tauri dev --no-watch --verbose -- -- --testnet
|
||||
|
||||
tauri-mainnet:
|
||||
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
|
||||
# E.g code2prompt . --exclude "*.lock" --exclude ".sqlx/*" --exclude "target"
|
||||
code2prompt_single_crate crate:
|
||||
cd {{crate}} && code2prompt . --exclude "*.lock" --exclude ".sqlx/*" --exclude "target"
|
||||
cd {{crate}} && code2prompt . --exclude "*.lock" --exclude ".sqlx/*" --exclude "target"
|
||||
|
|
|
|||
|
|
@ -504,11 +504,7 @@ impl MoneroWallet {
|
|||
/// Sweep multiple addresses with different ratios
|
||||
/// If the address is `None`, the address will be set to the primary address of the
|
||||
/// main wallet.
|
||||
pub async fn sweep_multi(
|
||||
&self,
|
||||
addresses: &[Address],
|
||||
ratios: &[f64],
|
||||
) -> Result<TxReceipt> {
|
||||
pub async fn sweep_multi(&self, addresses: &[Address], ratios: &[f64]) -> Result<TxReceipt> {
|
||||
tracing::info!("`{}` sweeping multi ({:?})", self.name, ratios);
|
||||
self.balance().await?;
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ impl Database {
|
|||
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());
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ use proxy::{proxy_handler, stats_handler};
|
|||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub node_pool: Arc<NodePool>,
|
||||
pub http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
/// Manages background tasks for the RPC pool
|
||||
|
|
@ -59,6 +60,12 @@ pub struct ServerInfo {
|
|||
pub host: String,
|
||||
}
|
||||
|
||||
impl Into<String> for ServerInfo {
|
||||
fn into(self) -> String {
|
||||
format!("http://{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_app_with_receiver(
|
||||
config: Config,
|
||||
network: Network,
|
||||
|
|
@ -97,7 +104,20 @@ async fn create_app_with_receiver(
|
|||
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
|
||||
let app = Router::new()
|
||||
|
|
|
|||
|
|
@ -11,11 +11,16 @@ use uuid::Uuid;
|
|||
|
||||
use crate::AppState;
|
||||
|
||||
fn display_node(node: &(String, String, i64)) -> String {
|
||||
format!("{}://{}:{}", node.0, node.1, node.2)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum HandlerError {
|
||||
NoNodes,
|
||||
PoolError(String),
|
||||
RequestError(String),
|
||||
JsonRpcError(String),
|
||||
AllRequestsFailed(Vec<(String, String)>),
|
||||
}
|
||||
|
||||
|
|
@ -25,6 +30,7 @@ impl std::fmt::Display for HandlerError {
|
|||
HandlerError::NoNodes => write!(f, "No nodes available"),
|
||||
HandlerError::PoolError(msg) => write!(f, "Pool error: {}", msg),
|
||||
HandlerError::RequestError(msg) => write!(f, "Request error: {}", msg),
|
||||
HandlerError::JsonRpcError(msg) => write!(f, "JSON-RPC error: {}", msg),
|
||||
HandlerError::AllRequestsFailed(errors) => {
|
||||
write!(f, "All requests failed: [")?;
|
||||
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(
|
||||
client: &reqwest::Client,
|
||||
node_url: (String, String, i64),
|
||||
path: &str,
|
||||
method: &str,
|
||||
headers: &HeaderMap,
|
||||
body: Option<&[u8]>,
|
||||
) -> 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 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(
|
||||
client: &reqwest::Client,
|
||||
node_url: (String, String, i64),
|
||||
path: &str,
|
||||
method: &str,
|
||||
|
|
@ -180,7 +183,7 @@ async fn single_raw_request(
|
|||
) -> Result<(Response, (String, String, i64), f64), HandlerError> {
|
||||
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) => {
|
||||
let elapsed = start_time.elapsed();
|
||||
let latency_ms = elapsed.as_millis() as f64;
|
||||
|
|
@ -195,7 +198,7 @@ async fn single_raw_request(
|
|||
.map_err(|e| HandlerError::RequestError(format!("{:#?}", e)))?;
|
||||
|
||||
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
|
||||
|
|
@ -225,6 +228,7 @@ async fn sequential_requests(
|
|||
body: Option<&[u8]>,
|
||||
) -> Result<Response, HandlerError> {
|
||||
const POOL_SIZE: usize = 20;
|
||||
const MAX_JSONRPC_ERRORS: usize = 3;
|
||||
|
||||
// Extract JSON-RPC method for better logging
|
||||
let jsonrpc_method = if path == "/json_rpc" {
|
||||
|
|
@ -238,7 +242,7 @@ async fn sequential_requests(
|
|||
};
|
||||
|
||||
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
|
||||
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)) => {
|
||||
let (scheme, host, port) = &winning_node;
|
||||
let winning_node_display = format!("{}://{}:{}", scheme, host, port);
|
||||
|
|
@ -304,24 +317,61 @@ async fn sequential_requests(
|
|||
return Ok(response);
|
||||
}
|
||||
Err(e) => {
|
||||
collected_errors.push((node_display.clone(), e.to_string()));
|
||||
collected_errors.push((node.clone(), e.clone()));
|
||||
|
||||
debug!(
|
||||
"Request failed with node {} with error {} - trying next node...",
|
||||
"Request failed with node {}: {} - checking if we should fail fast...",
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
let detailed_errors: Vec<String> = collected_errors
|
||||
.iter()
|
||||
.map(|(node, error)| format!("{}: {}", node, error))
|
||||
.map(|(node, error)| format!("{}: {}", display_node(node), error))
|
||||
.collect();
|
||||
|
||||
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
|
||||
|
|
@ -400,6 +455,15 @@ async fn proxy_request(
|
|||
}
|
||||
})
|
||||
}
|
||||
HandlerError::JsonRpcError(msg) => {
|
||||
json!({
|
||||
"error": "JSON-RPC error",
|
||||
"details": {
|
||||
"type": "JsonRpcError",
|
||||
"message": msg
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
Response::builder()
|
||||
|
|
|
|||
23
monero-sys/.sqlx/query-7e58428584d28a238ab37a83662b88afcef6fc5246f11c85a35869f79da61c34.json
generated
Normal file
23
monero-sys/.sqlx/query-7e58428584d28a238ab37a83662b88afcef6fc5246f11c85a35869f79da61c34.json
generated
Normal 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"
|
||||
}
|
||||
12
monero-sys/.sqlx/query-a679f789c90ede34cd840d23d90087520dcf1777fdf4cc3ed7aab0c9d70d060c.json
generated
Normal file
12
monero-sys/.sqlx/query-a679f789c90ede34cd840d23d90087520dcf1777fdf4cc3ed7aab0c9d70d060c.json
generated
Normal 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"
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ edition = "2021"
|
|||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
backoff = { version = "0.4.0", features = ["futures", "tokio"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
cxx = "1.0.137"
|
||||
monero = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
56
monero-sys/regenerate_sqlx_cache.sh
Executable file
56
monero-sys/regenerate_sqlx_cache.sh
Executable 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."
|
||||
|
|
@ -16,7 +16,9 @@
|
|||
* - CXX doesn't support static methods as yet, so we define free functions here that
|
||||
* simply call the appropriate static methods.
|
||||
* - 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
|
||||
* take the argument or not.
|
||||
*
|
||||
|
|
@ -58,6 +60,26 @@ namespace Monero
|
|||
auto addr = wallet.address(account_index, address_index);
|
||||
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`]
|
||||
|
|
@ -223,6 +245,16 @@ namespace Monero
|
|||
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)
|
||||
{
|
||||
return std::make_unique<std::string>(wallet.filename());
|
||||
|
|
@ -234,6 +266,156 @@ namespace Monero
|
|||
{
|
||||
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"
|
||||
|
|
@ -359,3 +541,64 @@ using StringMap = std::map<String, String>;
|
|||
using StringVec = std::vector<String>;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,10 +54,11 @@ pub mod ffi {
|
|||
/// A pending transaction.
|
||||
type PendingTransaction;
|
||||
|
||||
/// A wallet listener.
|
||||
///
|
||||
/// Can be attached to a wallet and will get notified upon specific events.
|
||||
type WalletListener;
|
||||
/// A struct containing transaction history.
|
||||
type TransactionHistory;
|
||||
|
||||
/// A struct containing a single transaction.
|
||||
type TransactionInfo;
|
||||
|
||||
/// Get the wallet manager.
|
||||
fn getWalletManager() -> Result<*mut WalletManager>;
|
||||
|
|
@ -100,6 +101,8 @@ pub mod ffi {
|
|||
seed_offset: &CxxString,
|
||||
) -> 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;
|
||||
unsafe fn openWallet(
|
||||
self: Pin<&mut WalletManager>,
|
||||
|
|
@ -117,9 +120,21 @@ pub mod ffi {
|
|||
store: 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.
|
||||
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").
|
||||
fn setDaemonAddress(self: Pin<&mut WalletManager>, address: &CxxString) -> Result<()>;
|
||||
|
||||
|
|
@ -160,6 +175,9 @@ pub mod ffi {
|
|||
/// Get the seed of the wallet.
|
||||
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.
|
||||
fn getRefreshFromBlockHeight(self: &Wallet) -> Result<u64>;
|
||||
|
||||
|
|
@ -181,6 +199,9 @@ pub mod ffi {
|
|||
/// Get the current blockchain height.
|
||||
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.
|
||||
fn daemonBlockChainTargetHeight(self: &Wallet) -> Result<u64>;
|
||||
|
||||
|
|
@ -199,6 +220,17 @@ pub mod ffi {
|
|||
/// Force a specific restore height.
|
||||
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.
|
||||
fn setAllowMismatchedDaemonVersion(
|
||||
self: Pin<&mut Wallet>,
|
||||
|
|
@ -255,6 +287,12 @@ pub mod ffi {
|
|||
tx: &PendingTransaction,
|
||||
) -> 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.
|
||||
fn walletGetTxKey(wallet: &Wallet, txid: &CxxString) -> Result<UniquePtr<CxxString>>;
|
||||
|
||||
|
|
@ -271,6 +309,36 @@ pub mod ffi {
|
|||
tx: *mut PendingTransaction,
|
||||
) -> 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.
|
||||
fn signMessage(
|
||||
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.
|
||||
/// It is called every time C++ issues a log message.
|
||||
///
|
||||
|
|
|
|||
86
monero-sys/src/database.rs
Normal file
86
monero-sys/src/database.rs
Normal 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
|
|
@ -33,50 +33,68 @@ async fn test_sign_message() {
|
|||
|
||||
// Test message to sign
|
||||
let test_message = "Hello, World! This is a test message for signing.";
|
||||
|
||||
|
||||
tracing::info!("Testing message signing with spend key (default address)");
|
||||
let signature_spend = wallet
|
||||
.sign_message(test_message, None, false)
|
||||
.await
|
||||
.expect("Failed to sign message with spend key");
|
||||
|
||||
|
||||
tracing::info!("Signature with spend key: {}", signature_spend);
|
||||
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)");
|
||||
let signature_view = wallet
|
||||
.sign_message(test_message, None, true)
|
||||
.await
|
||||
.expect("Failed to sign message with view key");
|
||||
|
||||
|
||||
tracing::info!("Signature with view key: {}", signature_view);
|
||||
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
|
||||
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)");
|
||||
let signature_explicit = wallet
|
||||
.sign_message(test_message, Some(&main_address.to_string()), false)
|
||||
.await
|
||||
.expect("Failed to sign message with explicit address");
|
||||
|
||||
|
||||
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
|
||||
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");
|
||||
let signature_empty = wallet
|
||||
.sign_message("", None, false)
|
||||
.await
|
||||
.expect("Failed to sign empty message");
|
||||
|
||||
|
||||
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!");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
948
src-gui/package-lock.json
generated
948
src-gui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,9 +4,9 @@
|
|||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"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",
|
||||
"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": "typeshare --lang=typescript --output-file ./src/models/tauriModel.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src && dprint fmt ./src/models/tauriModel.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 ../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 ../monero-sys/src && dprint fmt ./src/models/tauriModel.ts",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"dev": "vite",
|
||||
|
|
@ -22,10 +22,12 @@
|
|||
"@mui/icons-material": "^7.1.1",
|
||||
"@mui/lab": "^7.0.0-beta.13",
|
||||
"@mui/material": "^7.1.1",
|
||||
"@mui/x-date-pickers": "^8.8.0",
|
||||
"@reduxjs/toolkit": "^2.3.0",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-cli": "^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-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
|
|
@ -33,6 +35,7 @@
|
|||
"@tauri-apps/plugin-updater": "2.7.1",
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"boring-avatars": "^1.11.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"humanize-duration": "^3.32.1",
|
||||
"jdenticon": "^3.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
SelectMakerDetails,
|
||||
TauriBackgroundProgress,
|
||||
TauriSwapProgressEvent,
|
||||
SendMoneroDetails,
|
||||
} from "./tauriModel";
|
||||
|
||||
export type TauriSwapProgressEventType = TauriSwapProgressEvent["type"];
|
||||
|
|
@ -310,10 +311,13 @@ export type PendingSelectMakerApprovalRequest = PendingApprovalRequest & {
|
|||
request: { type: "SelectMaker"; content: SelectMakerDetails };
|
||||
};
|
||||
|
||||
export interface SortableQuoteWithAddress extends QuoteWithAddress {
|
||||
expiration_ts?: number;
|
||||
request_id?: string;
|
||||
}
|
||||
export type PendingSendMoneroApprovalRequest = PendingApprovalRequest & {
|
||||
request: { type: "SendMonero"; content: SendMoneroDetails };
|
||||
};
|
||||
|
||||
export type PendingPasswordApprovalRequest = PendingApprovalRequest & {
|
||||
request: { type: "PasswordRequest"; content: { wallet_path: string } };
|
||||
};
|
||||
|
||||
export function isPendingSelectMakerApprovalEvent(
|
||||
event: ApprovalRequest,
|
||||
|
|
@ -327,6 +331,30 @@ export function isPendingSelectMakerApprovalEvent(
|
|||
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
|
||||
* Returns true for events where funds have been locked
|
||||
|
|
|
|||
|
|
@ -24,9 +24,16 @@ import {
|
|||
listSellersAtRendezvousPoint,
|
||||
refreshApprovals,
|
||||
updateAllNodeStatuses,
|
||||
fetchAndUpdateBackgroundItems,
|
||||
fetchAndUpdateApprovalItems,
|
||||
} from "./rpc";
|
||||
import { store } from "./store/storeRenderer";
|
||||
import { exhaustiveGuard } from "utils/typescriptUtils";
|
||||
import {
|
||||
setBalance,
|
||||
setHistory,
|
||||
setSyncProgress,
|
||||
} from "store/features/walletSlice";
|
||||
|
||||
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
|
||||
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;
|
||||
|
||||
function setIntervalImmediate(callback: () => void, interval: number): void {
|
||||
|
|
@ -137,6 +144,19 @@ export async function setupBackgroundTasks(): Promise<void> {
|
|||
store.dispatch(poolStatusReceived(eventData));
|
||||
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:
|
||||
exhaustiveGuard(channelName);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ import { setupBackgroundTasks } from "renderer/background";
|
|||
import "@fontsource/roboto";
|
||||
import FeedbackPage from "./pages/feedback/FeedbackPage";
|
||||
import IntroductionModal from "./modal/introduction/IntroductionModal";
|
||||
import MoneroWalletPage from "./pages/monero/MoneroWalletPage";
|
||||
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" {
|
||||
interface Theme {
|
||||
|
|
@ -44,16 +48,19 @@ export default function App() {
|
|||
return (
|
||||
<StyledEngineProvider injectFirst>
|
||||
<ThemeProvider theme={currentTheme}>
|
||||
<CssBaseline />
|
||||
<GlobalSnackbarProvider>
|
||||
<IntroductionModal />
|
||||
<SeedSelectionDialog />
|
||||
<Router>
|
||||
<Navigation />
|
||||
<InnerContent />
|
||||
<UpdaterDialog />
|
||||
</Router>
|
||||
</GlobalSnackbarProvider>
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<CssBaseline />
|
||||
<GlobalSnackbarProvider>
|
||||
<IntroductionModal />
|
||||
<SeedSelectionDialog />
|
||||
<PasswordEntryDialog />
|
||||
<Router>
|
||||
<Navigation />
|
||||
<InnerContent />
|
||||
<UpdaterDialog />
|
||||
</Router>
|
||||
</GlobalSnackbarProvider>
|
||||
</LocalizationProvider>
|
||||
</ThemeProvider>
|
||||
</StyledEngineProvider>
|
||||
);
|
||||
|
|
@ -70,12 +77,13 @@ function InnerContent() {
|
|||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<MoneroWalletPage />} />
|
||||
<Route path="/monero-wallet" element={<MoneroWalletPage />} />
|
||||
<Route path="/swap" element={<SwapPage />} />
|
||||
<Route path="/history" element={<HistoryPage />} />
|
||||
<Route path="/wallet" element={<WalletPage />} />
|
||||
<Route path="/bitcoin-wallet" element={<WalletPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/feedback" element={<FeedbackPage />} />
|
||||
<Route path="/" element={<SwapPage />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -186,18 +186,11 @@ export default function DaemonStatusAlert() {
|
|||
const contextStatus = useAppSelector((s) => s.rpc.status);
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (
|
||||
contextStatus === null ||
|
||||
contextStatus === TauriContextStatusEvent.NotInitialized
|
||||
) {
|
||||
return (
|
||||
<LoadingSpinnerAlert severity="warning">
|
||||
Checking for available remote nodes
|
||||
</LoadingSpinnerAlert>
|
||||
);
|
||||
}
|
||||
|
||||
switch (contextStatus) {
|
||||
case null:
|
||||
return null;
|
||||
case TauriContextStatusEvent.NotInitialized:
|
||||
return null;
|
||||
case TauriContextStatusEvent.Initializing:
|
||||
return null;
|
||||
case TauriContextStatusEvent.Available:
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export default function FundsLeftInWalletAlert() {
|
|||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => navigate("/wallet")}
|
||||
onClick={() => navigate("/bitcoin-wallet")}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import React from "react";
|
||||
import { Box, Alert, AlertTitle } from "@mui/material";
|
||||
import {
|
||||
BobStateName,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ type MoneroAddressTextFieldProps = TextFieldProps & {
|
|||
address: string;
|
||||
onAddressChange: (address: string) => void;
|
||||
onAddressValidityChange: (valid: boolean) => void;
|
||||
helperText: string;
|
||||
helperText?: string;
|
||||
};
|
||||
|
||||
export default function MoneroAddressTextField({
|
||||
|
|
|
|||
178
src-gui/src/renderer/components/inputs/NumberInput.tsx
Normal file
178
src-gui/src/renderer/components/inputs/NumberInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
|
|
@ -10,17 +9,43 @@ import {
|
|||
RadioGroup,
|
||||
TextField,
|
||||
Typography,
|
||||
Button,
|
||||
Box,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Divider,
|
||||
Card,
|
||||
CardContent,
|
||||
} from "@mui/material";
|
||||
import { useState, useEffect } from "react";
|
||||
import { usePendingSeedSelectionApproval } from "store/hooks";
|
||||
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() {
|
||||
const pendingApprovals = usePendingSeedSelectionApproval();
|
||||
const [selectedOption, setSelectedOption] = useState<string>("RandomSeed");
|
||||
const [selectedOption, setSelectedOption] = useState<
|
||||
SeedChoice["type"] | undefined
|
||||
>("RandomSeed");
|
||||
const [customSeed, setCustomSeed] = useState<string>("");
|
||||
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(() => {
|
||||
if (selectedOption === "FromSeed" && customSeed.trim()) {
|
||||
|
|
@ -36,51 +61,193 @@ export default function SeedSelectionDialog() {
|
|||
}
|
||||
}, [customSeed, selectedOption]);
|
||||
|
||||
const handleClose = async (accept: boolean) => {
|
||||
if (!approval) return;
|
||||
|
||||
if (accept) {
|
||||
const seedChoice =
|
||||
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" });
|
||||
// Auto-select the first recent wallet if available
|
||||
useEffect(() => {
|
||||
if (recentWallets.length > 0) {
|
||||
setSelectedOption("FromWalletPath");
|
||||
setWalletPath(recentWallets[0]);
|
||||
}
|
||||
}, [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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={true} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Monero Wallet</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
Choose what seed to use for the wallet.
|
||||
</Typography>
|
||||
// Disable the button if the user is restoring from a seed and the seed is invalid
|
||||
// or if selecting wallet path and no path is selected
|
||||
const isDisabled =
|
||||
selectedOption === "FromSeed"
|
||||
? customSeed.trim().length === 0 || !isSeedValid
|
||||
: selectedOption === "FromWalletPath"
|
||||
? !walletPath
|
||||
: false;
|
||||
|
||||
<FormControl component="fieldset">
|
||||
<RadioGroup
|
||||
value={selectedOption}
|
||||
onChange={(e) => setSelectedOption(e.target.value)}
|
||||
return (
|
||||
<Dialog
|
||||
open={true}
|
||||
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
|
||||
value="RandomSeed"
|
||||
control={<Radio />}
|
||||
label="Create a new wallet"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="FromSeed"
|
||||
control={<Radio />}
|
||||
label="Restore wallet from seed"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<FolderOpenIcon sx={{ fontSize: 32, color: "text.secondary" }} />
|
||||
<Typography
|
||||
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" && (
|
||||
<TextField
|
||||
|
|
@ -90,7 +257,6 @@ export default function SeedSelectionDialog() {
|
|||
label="Enter your seed phrase"
|
||||
value={customSeed}
|
||||
onChange={(e) => setCustomSeed(e.target.value)}
|
||||
sx={{ mt: 2 }}
|
||||
placeholder="Enter your Monero 25 words seed phrase..."
|
||||
error={!isSeedValid && customSeed.length > 0}
|
||||
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>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => handleClose(true)}
|
||||
variant="contained"
|
||||
disabled={
|
||||
selectedOption === "FromSeed"
|
||||
? !customSeed.trim() || !isSeedValid
|
||||
: false
|
||||
}
|
||||
<DialogActions sx={{ justifyContent: "space-between" }}>
|
||||
<PromiseInvokeButton
|
||||
variant="text"
|
||||
onInvoke={Legacy}
|
||||
requiresContext={false}
|
||||
color="inherit"
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
No wallet (Legacy)
|
||||
</PromiseInvokeButton>
|
||||
<PromiseInvokeButton
|
||||
onInvoke={accept}
|
||||
variant="contained"
|
||||
disabled={isDisabled}
|
||||
requiresContext={false}
|
||||
>
|
||||
Continue
|
||||
</PromiseInvokeButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ export default function DebugPage() {
|
|||
}}
|
||||
>
|
||||
<CliLogsBox
|
||||
minHeight="min(20rem, 70vh)"
|
||||
logs={logs}
|
||||
label="Logs relevant to the swap (only current session)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -5,26 +5,35 @@ import SwapHorizOutlinedIcon from "@mui/icons-material/SwapHorizOutlined";
|
|||
import FeedbackOutlinedIcon from "@mui/icons-material/FeedbackOutlined";
|
||||
import RouteListItemIconButton from "./RouteListItemIconButton";
|
||||
import UnfinishedSwapsBadge from "./UnfinishedSwapsCountBadge";
|
||||
import { useTotalUnreadMessagesCount } from "store/hooks";
|
||||
import { useIsSwapRunning, useTotalUnreadMessagesCount } from "store/hooks";
|
||||
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() {
|
||||
const totalUnreadCount = useTotalUnreadMessagesCount();
|
||||
const isSwapRunning = useIsSwapRunning();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<List>
|
||||
<RouteListItemIconButton name="Swap" route="/swap">
|
||||
<SwapHorizOutlinedIcon />
|
||||
<RouteListItemIconButton name="Wallet" route={["/monero-wallet", "/"]}>
|
||||
<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 name="History" route="/history">
|
||||
<UnfinishedSwapsBadge>
|
||||
<HistoryOutlinedIcon />
|
||||
</UnfinishedSwapsBadge>
|
||||
</RouteListItemIconButton>
|
||||
<RouteListItemIconButton name="Wallet" route="/wallet">
|
||||
<AccountBalanceWalletIcon />
|
||||
</RouteListItemIconButton>
|
||||
<RouteListItemIconButton name="Feedback" route="/feedback">
|
||||
<Badge
|
||||
badgeContent={totalUnreadCount}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ListItemIcon, ListItemText } from "@mui/material";
|
||||
import { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
|
||||
import ListItemButton from "@mui/material/ListItemButton";
|
||||
|
||||
|
|
@ -10,13 +10,31 @@ export default function RouteListItemIconButton({
|
|||
children,
|
||||
}: {
|
||||
name: string;
|
||||
route: string;
|
||||
route: string[] | string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
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 (
|
||||
<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>
|
||||
<ListItemText primary={name} />
|
||||
</ListItemButton>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import React from "react";
|
||||
import { Badge } from "@mui/material";
|
||||
import { useResumeableSwapsCountExcludingPunished } from "store/hooks";
|
||||
import { useIsSwapRunning, useResumeableSwapsCountExcludingPunished } from "store/hooks";
|
||||
|
||||
export default function UnfinishedSwapsBadge({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const isSwapRunning = useIsSwapRunning();
|
||||
const resumableSwapsCount = useResumeableSwapsCountExcludingPunished();
|
||||
|
||||
if (resumableSwapsCount > 0) {
|
||||
const displayedResumableSwapsCount = isSwapRunning ? resumableSwapsCount - 1 : resumableSwapsCount;
|
||||
|
||||
if (displayedResumableSwapsCount > 0) {
|
||||
return (
|
||||
<Badge badgeContent={resumableSwapsCount} color="primary">
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ type Props = {
|
|||
content: string;
|
||||
displayCopyIcon?: boolean;
|
||||
enableQrCode?: boolean;
|
||||
light?: boolean;
|
||||
};
|
||||
|
||||
function QRCodeModal({ open, onClose, content }: ModalProps) {
|
||||
|
|
@ -57,6 +58,7 @@ export default function ActionableMonospaceTextBox({
|
|||
content,
|
||||
displayCopyIcon = true,
|
||||
enableQrCode = true,
|
||||
light = false,
|
||||
}: Props) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [qrCodeOpen, setQrCodeOpen] = useState(false);
|
||||
|
|
@ -88,7 +90,7 @@ export default function ActionableMonospaceTextBox({
|
|||
}}
|
||||
>
|
||||
<Box sx={{ flexGrow: 1 }} onClick={handleCopy}>
|
||||
<MonospaceTextBox>
|
||||
<MonospaceTextBox light={light}>
|
||||
{content}
|
||||
{displayCopyIcon && (
|
||||
<IconButton
|
||||
|
|
|
|||
|
|
@ -2,16 +2,18 @@ import { Box, Typography } from "@mui/material";
|
|||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
light?: boolean;
|
||||
};
|
||||
|
||||
export default function MonospaceTextBox({ children }: Props) {
|
||||
export default function MonospaceTextBox({ children, light = false }: Props) {
|
||||
return (
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.palette.grey[900],
|
||||
backgroundColor: light ? "transparent" : theme.palette.grey[900],
|
||||
borderRadius: 2,
|
||||
border: light ? `1px solid ${theme.palette.grey[800]}` : "none",
|
||||
padding: theme.spacing(1),
|
||||
})}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -63,11 +63,13 @@ export default function CliLogsBox({
|
|||
logs,
|
||||
topRightButton = null,
|
||||
autoScroll = false,
|
||||
minHeight,
|
||||
}: {
|
||||
label: string;
|
||||
logs: (CliLog | string)[];
|
||||
topRightButton?: ReactNode;
|
||||
autoScroll?: boolean;
|
||||
minHeight?: string;
|
||||
}) {
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
|
||||
|
|
@ -82,6 +84,7 @@ export default function CliLogsBox({
|
|||
|
||||
return (
|
||||
<ScrollablePaperTextBox
|
||||
minHeight={minHeight}
|
||||
title={label}
|
||||
copyValue={logsToRawString(logs)}
|
||||
searchQuery={searchQuery}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export default function ScrollablePaperTextBox({
|
|||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<VList ref={virtuaEl} style={{ height: MIN_HEIGHT, width: "100%" }}>
|
||||
<VList ref={virtuaEl} style={{ height: "100vh", width: "100%" }}>
|
||||
{rows}
|
||||
</VList>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export default function TruncatedText({
|
|||
let finalChildren = children ?? "";
|
||||
|
||||
const truncatedText =
|
||||
finalChildren.length > limit
|
||||
finalChildren.length > limit
|
||||
? truncateMiddle
|
||||
? finalChildren.slice(0, Math.floor(limit / 2)) +
|
||||
ellipsis +
|
||||
|
|
|
|||
|
|
@ -32,16 +32,43 @@ export function AmountWithUnit({
|
|||
return (
|
||||
<Tooltip arrow title={title}>
|
||||
<span>
|
||||
{amount != null
|
||||
? Number.parseFloat(amount.toFixed(fixedPrecision))
|
||||
: "?"}{" "}
|
||||
{unit}
|
||||
{amount != null ? amount.toFixed(fixedPrecision) : "?"} {unit}
|
||||
{parenthesisText != null ? ` (${parenthesisText})` : null}
|
||||
</span>
|
||||
</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 = {
|
||||
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);
|
||||
|
||||
return (
|
||||
<AmountWithUnit
|
||||
amount={amount}
|
||||
unit="XMR"
|
||||
fixedPrecision={4}
|
||||
fixedPrecision={fixedPrecision}
|
||||
exchangeRate={xmrRate}
|
||||
/>
|
||||
);
|
||||
|
|
@ -128,8 +161,17 @@ export function SatsAmount({ amount }: { amount: Amount }) {
|
|||
return <BitcoinAmount amount={btcAmount} />;
|
||||
}
|
||||
|
||||
export function PiconeroAmount({ amount }: { amount: Amount }) {
|
||||
export function PiconeroAmount({
|
||||
amount,
|
||||
fixedPrecision = 8,
|
||||
}: {
|
||||
amount: Amount;
|
||||
fixedPrecision?: number;
|
||||
}) {
|
||||
return (
|
||||
<MoneroAmount amount={amount == null ? null : piconerosToXmr(amount)} />
|
||||
<MoneroAmount
|
||||
amount={amount == null ? null : piconerosToXmr(amount)}
|
||||
fixedPrecision={fixedPrecision}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import HistoryTable from "./table/HistoryTable";
|
|||
export default function HistoryPage() {
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h3">History</Typography>
|
||||
<SwapTxLockAlertsBox />
|
||||
<HistoryTable />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
Skeleton,
|
||||
} from "@mui/material";
|
||||
import { useSwapInfosSortedByDate } from "../../../../../store/hooks";
|
||||
import HistoryRow from "./HistoryRow";
|
||||
|
|
@ -23,19 +25,75 @@ export default function HistoryTable() {
|
|||
>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell />
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Amount</TableCell>
|
||||
<TableCell>State</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
{swapSortedByDate.length > 0 && (
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell />
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Amount</TableCell>
|
||||
<TableCell>State</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableBody>
|
||||
{swapSortedByDate.map((swap) => (
|
||||
<HistoryRow {...swap} key={swap.swap_id} />
|
||||
))}
|
||||
{swapSortedByDate.length === 0 ? (
|
||||
<>
|
||||
<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>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,11 @@ export default function SwapLogFileOpenButton({
|
|||
<Dialog open onClose={() => setLogs(null)} fullWidth maxWidth="lg">
|
||||
<DialogTitle>Logs of swap {swapId}</DialogTitle>
|
||||
<DialogContent>
|
||||
<CliLogsBox logs={logs} label="Logs relevant to the swap" />
|
||||
<CliLogsBox
|
||||
minHeight="min(20rem, 70vh)"
|
||||
logs={logs}
|
||||
label="Logs relevant to the swap"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"}
|
||||
>
|
||||
<MAX>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { ReactNode } from "react";
|
||||
import { Box, Link, Typography } from "@mui/material";
|
||||
import { ReactNode } from "react";
|
||||
import InfoBox from "./InfoBox";
|
||||
import TruncatedText from "renderer/components/other/TruncatedText";
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ export default function InitPage() {
|
|||
useState(false);
|
||||
|
||||
// We force this to true for now because the internal wallet is not really accessible from the GUI yet
|
||||
const [useExternalRedeemAddress, _setUseExternalRedeemAddress] =
|
||||
useState(true);
|
||||
const [useExternalRedeemAddress, setUseExternalRedeemAddress] =
|
||||
useState(true);
|
||||
|
||||
const [redeemAddressValid, setRedeemAddressValid] = useState(false);
|
||||
const [refundAddressValid, setRefundAddressValid] = useState(false);
|
||||
|
|
@ -40,14 +40,35 @@ export default function InitPage() {
|
|||
}}
|
||||
>
|
||||
<Paper variant="outlined" style={{}}>
|
||||
<MoneroAddressTextField
|
||||
label="Monero redeem address"
|
||||
address={redeemAddress}
|
||||
onAddressChange={setRedeemAddress}
|
||||
onAddressValidityChange={setRedeemAddressValid}
|
||||
fullWidth
|
||||
helperText="The monero will be sent to this address"
|
||||
/>
|
||||
<Tabs
|
||||
value={useExternalRedeemAddress ? 1 : 0}
|
||||
indicatorColor="primary"
|
||||
variant="fullWidth"
|
||||
onChange={(_, newValue) =>
|
||||
setUseExternalRedeemAddress(newValue === 1)
|
||||
}
|
||||
>
|
||||
<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 variant="outlined" style={{}}>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export default function WalletPage() {
|
|||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h3">Wallet</Typography>
|
||||
<Alert severity="info">
|
||||
You do not have to deposit money before starting a swap. Instead, you
|
||||
will be greeted with a deposit address after you initiate one.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,23 @@
|
|||
import { createTheme, ThemeOptions } from "@mui/material";
|
||||
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 {
|
||||
Light = "light",
|
||||
Dark = "dark",
|
||||
|
|
@ -33,7 +50,61 @@ const baseTheme: ThemeOptions = {
|
|||
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: {
|
||||
defaultProps: {
|
||||
|
|
|
|||
|
|
@ -31,16 +31,31 @@ import {
|
|||
RedactResponse,
|
||||
GetCurrentSwapResponse,
|
||||
LabeledMoneroAddress,
|
||||
GetPendingApprovalsArgs,
|
||||
GetMoneroHistoryResponse,
|
||||
GetMoneroMainAddressResponse,
|
||||
GetMoneroBalanceResponse,
|
||||
SendMoneroArgs,
|
||||
SendMoneroResponse,
|
||||
GetMoneroSyncProgressResponse,
|
||||
GetPendingApprovalsResponse,
|
||||
RejectApprovalArgs,
|
||||
RejectApprovalResponse,
|
||||
SetRestoreHeightArgs,
|
||||
SetRestoreHeightResponse,
|
||||
GetRestoreHeightResponse,
|
||||
} from "models/tauriModel";
|
||||
import {
|
||||
rpcSetBalance,
|
||||
rpcSetSwapInfo,
|
||||
approvalRequestsReplaced,
|
||||
} from "store/features/rpcSlice";
|
||||
import {
|
||||
setMainAddress,
|
||||
setBalance,
|
||||
setSyncProgress,
|
||||
setHistory,
|
||||
} from "store/features/walletSlice";
|
||||
import { store } from "./store/storeRenderer";
|
||||
import { Maker } from "models/apiModel";
|
||||
import { providerToConcatenatedMultiAddr } from "utils/multiAddrUtils";
|
||||
import { MoneroRecoveryResponse } from "models/rpcModel";
|
||||
import { ListSellersResponse } from "../models/tauriModel";
|
||||
|
|
@ -417,6 +432,129 @@ export async function getMoneroAddresses(): Promise<GetMoneroAddressesResponse>
|
|||
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> {
|
||||
const testnet = isTestnet();
|
||||
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,
|
||||
accept: object,
|
||||
accept: T,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await invoke<ResolveApprovalArgs, ResolveApprovalResponse>(
|
||||
"resolve_approval_request",
|
||||
{ request_id: requestId, accept },
|
||||
{ request_id: requestId, accept: accept as object },
|
||||
);
|
||||
} catch (error) {
|
||||
// Refresh approval list when resolve fails to keep UI in sync
|
||||
} finally {
|
||||
// Always refresh the approval list
|
||||
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> {
|
||||
const response = await invokeNoArgs<GetPendingApprovalsResponse>(
|
||||
"get_pending_approvals",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import settingsSlice from "./features/settingsSlice";
|
|||
import nodesSlice from "./features/nodesSlice";
|
||||
import conversationsSlice from "./features/conversationsSlice";
|
||||
import poolSlice from "./features/poolSlice";
|
||||
import walletSlice from "./features/walletSlice";
|
||||
|
||||
export const reducers = {
|
||||
swap: swapReducer,
|
||||
|
|
@ -18,4 +19,5 @@ export const reducers = {
|
|||
nodes: nodesSlice,
|
||||
conversations: conversationsSlice,
|
||||
pool: poolSlice,
|
||||
wallet: walletSlice,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -156,6 +156,18 @@ export const rpcSlice = createSlice({
|
|||
backgroundProgressEventRemoved(slice, action: PayloadAction<string>) {
|
||||
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,
|
||||
backgroundProgressEventReceived,
|
||||
backgroundProgressEventRemoved,
|
||||
rpcSetBackgroundItems,
|
||||
rpcSetApprovalItems,
|
||||
} = rpcSlice.actions;
|
||||
|
||||
export default rpcSlice.reducer;
|
||||
|
|
|
|||
65
src-gui/src/store/features/walletSlice.ts
Normal file
65
src-gui/src/store/features/walletSlice.ts
Normal 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;
|
||||
|
|
@ -12,6 +12,10 @@ import {
|
|||
isPendingSelectMakerApprovalEvent,
|
||||
haveFundsBeenLocked,
|
||||
PendingSeedSelectionApprovalRequest,
|
||||
PendingSendMoneroApprovalRequest,
|
||||
isPendingSendMoneroApprovalEvent,
|
||||
PendingPasswordApprovalRequest,
|
||||
isPendingPasswordApprovalEvent,
|
||||
} from "models/tauriModelExt";
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
|
||||
import type { AppDispatch, RootState } from "renderer/store/storeRenderer";
|
||||
|
|
@ -207,6 +211,11 @@ export function usePendingLockBitcoinApproval(): PendingLockBitcoinApprovalReque
|
|||
return approvals.filter((c) => isPendingLockBitcoinApprovalEvent(c));
|
||||
}
|
||||
|
||||
export function usePendingSendMoneroApproval(): PendingSendMoneroApprovalRequest[] {
|
||||
const approvals = usePendingApprovals();
|
||||
return approvals.filter((c) => isPendingSendMoneroApprovalEvent(c));
|
||||
}
|
||||
|
||||
export function usePendingSelectMakerApproval(): PendingSelectMakerApprovalRequest[] {
|
||||
const approvals = usePendingApprovals();
|
||||
return approvals.filter((c) => isPendingSelectMakerApprovalEvent(c));
|
||||
|
|
@ -217,6 +226,11 @@ export function usePendingSeedSelectionApproval(): PendingSeedSelectionApprovalR
|
|||
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
|
||||
/// In the format [id, {componentName, {type: "Pending", content: {consumed, total}}}]
|
||||
export function usePendingBackgroundProcesses(): [
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
updateAllNodeStatuses,
|
||||
fetchSellersAtPresetRendezvousPoints,
|
||||
getSwapInfo,
|
||||
initializeMoneroWallet,
|
||||
} from "renderer/rpc";
|
||||
import logger from "utils/logger";
|
||||
import { contextStatusEventReceived } from "store/features/rpcSlice";
|
||||
|
|
@ -69,6 +70,7 @@ export function createMainListeners() {
|
|||
checkBitcoinBalance(),
|
||||
getAllSwapInfos(),
|
||||
fetchSellersAtPresetRendezvousPoints(),
|
||||
initializeMoneroWallet(),
|
||||
]);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ export function piconerosToXmr(piconeros: number): number {
|
|||
return piconeros / 1000000000000;
|
||||
}
|
||||
|
||||
export function xmrToPiconeros(xmr: number): number {
|
||||
return Math.ceil(xmr * 1000000000000);
|
||||
}
|
||||
|
||||
export function isXmrAddressValid(address: string, stagenet: boolean) {
|
||||
const re = stagenet
|
||||
? "^(?:[57][0-9A-Za-z]{94}|[57][0-9A-Za-z]{105})$"
|
||||
|
|
|
|||
3345
src-gui/yarn.lock
3345
src-gui/yarn.lock
File diff suppressed because it is too large
Load diff
|
|
@ -21,7 +21,6 @@ rustls = { version = "0.23.26", default-features = false, features = ["ring"] }
|
|||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
swap = { path = "../swap", features = [ "tauri" ] }
|
||||
sysinfo = "=0.32.1"
|
||||
tauri = { version = "^2.0.0", features = [ "config-json5" ] }
|
||||
tauri-plugin-clipboard-manager = "^2.0.0"
|
||||
tauri-plugin-dialog = "2.2.2"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@
|
|||
"cli:allow-cli-matches",
|
||||
"updater:default",
|
||||
"process:allow-restart",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
use anyhow::Context as AnyhowContext;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::result::Result;
|
||||
|
|
@ -10,9 +9,12 @@ use swap::cli::{
|
|||
BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, CheckElectrumNodeArgs,
|
||||
CheckElectrumNodeResponse, CheckMoneroNodeArgs, CheckMoneroNodeResponse, CheckSeedArgs,
|
||||
CheckSeedResponse, ExportBitcoinWalletArgs, GetCurrentSwapArgs, GetDataDirArgs,
|
||||
GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, GetPendingApprovalsResponse,
|
||||
GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs,
|
||||
ResolveApprovalArgs, ResumeSwapArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs,
|
||||
GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, GetMoneroBalanceArgs,
|
||||
GetMoneroHistoryArgs, GetMoneroMainAddressArgs, GetMoneroSyncProgressArgs,
|
||||
GetPendingApprovalsResponse, GetRestoreHeightArgs, GetSwapInfoArgs,
|
||||
GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs,
|
||||
RejectApprovalArgs, RejectApprovalResponse, ResolveApprovalArgs, ResumeSwapArgs,
|
||||
SendMoneroArgs, SetRestoreHeightArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs,
|
||||
},
|
||||
tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings},
|
||||
Context, ContextBuilder,
|
||||
|
|
@ -64,11 +66,11 @@ macro_rules! tauri_command {
|
|||
($fn_name:ident, $request_name:ident) => {
|
||||
#[tauri::command]
|
||||
async fn $fn_name(
|
||||
context: tauri::State<'_, RwLock<State>>,
|
||||
state: tauri::State<'_, State>,
|
||||
args: $request_name,
|
||||
) -> Result<<$request_name as swap::cli::api::request::Request>::Response, String> {
|
||||
// Throw error if context is not available
|
||||
let context = context.read().await.try_get_context()?;
|
||||
let context = state.try_get_context()?;
|
||||
|
||||
<$request_name as swap::cli::api::request::Request>::request(args, context)
|
||||
.await
|
||||
|
|
@ -78,10 +80,10 @@ macro_rules! tauri_command {
|
|||
($fn_name:ident, $request_name:ident, no_args) => {
|
||||
#[tauri::command]
|
||||
async fn $fn_name(
|
||||
context: tauri::State<'_, RwLock<State>>,
|
||||
state: tauri::State<'_, State>,
|
||||
) -> Result<<$request_name as swap::cli::api::request::Request>::Response, String> {
|
||||
// Throw error if context is not available
|
||||
let context = context.read().await.try_get_context()?;
|
||||
let context = state.try_get_context()?;
|
||||
|
||||
<$request_name as swap::cli::api::request::Request>::request($request_name {}, context)
|
||||
.await
|
||||
|
|
@ -92,7 +94,7 @@ macro_rules! tauri_command {
|
|||
|
||||
/// Represents the shared Tauri state. It is accessed by Tauri commands
|
||||
struct State {
|
||||
pub context: Option<Arc<Context>>,
|
||||
pub context: RwLock<Option<Arc<Context>>>,
|
||||
pub handle: TauriHandle,
|
||||
}
|
||||
|
||||
|
|
@ -100,22 +102,17 @@ impl State {
|
|||
/// Creates a new State instance with no Context
|
||||
fn new(handle: TauriHandle) -> Self {
|
||||
Self {
|
||||
context: None,
|
||||
context: RwLock::new(None),
|
||||
handle,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the context for the application state
|
||||
/// This is typically called after the Context has been initialized
|
||||
/// in the setup function
|
||||
fn set_context(&mut self, context: impl Into<Option<Arc<Context>>>) {
|
||||
self.context = context.into();
|
||||
}
|
||||
|
||||
/// Attempts to retrieve the context
|
||||
/// Returns an error if the context is not available
|
||||
fn try_get_context(&self) -> Result<Arc<Context>, String> {
|
||||
self.context
|
||||
.try_read()
|
||||
.map_err(|_| "Context is being modified".to_string())?
|
||||
.clone()
|
||||
.ok_or("Context not available".to_string())
|
||||
}
|
||||
|
|
@ -149,8 +146,8 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
|||
// We need to set a value for the Tauri state right at the start
|
||||
// If we don't do this, Tauri commands will panic at runtime if no value is present
|
||||
let handle = TauriHandle::new(app_handle.clone());
|
||||
let state = RwLock::new(State::new(handle));
|
||||
app_handle.manage::<RwLock<State>>(state);
|
||||
let state = State::new(handle);
|
||||
app_handle.manage::<State>(state);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -203,8 +200,16 @@ pub fn run() {
|
|||
resolve_approval_request,
|
||||
redact,
|
||||
save_txt_files,
|
||||
get_monero_history,
|
||||
get_monero_main_address,
|
||||
get_monero_balance,
|
||||
send_monero,
|
||||
get_monero_sync_progress,
|
||||
check_seed,
|
||||
get_pending_approvals,
|
||||
set_monero_restore_height,
|
||||
reject_approval_request,
|
||||
get_restore_height
|
||||
])
|
||||
.setup(setup)
|
||||
.build(tauri::generate_context!())
|
||||
|
|
@ -215,18 +220,17 @@ pub fn run() {
|
|||
// This is necessary to among other things stop the monero-wallet-rpc process
|
||||
// If the application is forcibly closed, this may not be called.
|
||||
// TODO: fix that
|
||||
let context = app.state::<RwLock<State>>().inner().try_read();
|
||||
let state = app.state::<State>();
|
||||
let context_to_cleanup = if let Ok(context_lock) = state.context.try_read() {
|
||||
context_lock.clone()
|
||||
} else {
|
||||
println!("Failed to acquire lock on context");
|
||||
None
|
||||
};
|
||||
|
||||
match context {
|
||||
Ok(context) => {
|
||||
if let Some(context) = context.context.as_ref() {
|
||||
if let Err(err) = context.cleanup() {
|
||||
println!("Cleanup failed {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
println!("Failed to acquire lock on context: {}", err);
|
||||
if let Some(context) = context_to_cleanup {
|
||||
if let Err(err) = context.cleanup() {
|
||||
println!("Cleanup failed {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -246,6 +250,7 @@ tauri_command!(get_logs, GetLogsArgs);
|
|||
tauri_command!(list_sellers, ListSellersArgs);
|
||||
tauri_command!(cancel_and_refund, CancelAndRefundArgs);
|
||||
tauri_command!(redact, RedactArgs);
|
||||
tauri_command!(send_monero, SendMoneroArgs);
|
||||
|
||||
// These commands require no arguments
|
||||
tauri_command!(get_wallet_descriptor, ExportBitcoinWalletArgs, no_args);
|
||||
|
|
@ -254,19 +259,25 @@ tauri_command!(get_swap_info, GetSwapInfoArgs);
|
|||
tauri_command!(get_swap_infos_all, GetSwapInfosAllArgs, no_args);
|
||||
tauri_command!(get_history, GetHistoryArgs, no_args);
|
||||
tauri_command!(get_monero_addresses, GetMoneroAddressesArgs, no_args);
|
||||
tauri_command!(get_monero_history, GetMoneroHistoryArgs, no_args);
|
||||
tauri_command!(get_current_swap, GetCurrentSwapArgs, no_args);
|
||||
tauri_command!(set_monero_restore_height, SetRestoreHeightArgs);
|
||||
tauri_command!(get_restore_height, GetRestoreHeightArgs, no_args);
|
||||
tauri_command!(get_monero_main_address, GetMoneroMainAddressArgs, no_args);
|
||||
tauri_command!(get_monero_balance, GetMoneroBalanceArgs, no_args);
|
||||
tauri_command!(get_monero_sync_progress, GetMoneroSyncProgressArgs, no_args);
|
||||
|
||||
/// Here we define Tauri commands whose implementation is not delegated to the Request trait
|
||||
#[tauri::command]
|
||||
async fn is_context_available(context: tauri::State<'_, RwLock<State>>) -> Result<bool, String> {
|
||||
async fn is_context_available(state: tauri::State<'_, State>) -> Result<bool, String> {
|
||||
// TODO: Here we should return more information about status of the context (e.g. initializing, failed)
|
||||
Ok(context.read().await.try_get_context().is_ok())
|
||||
Ok(state.try_get_context().is_ok())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_monero_node(
|
||||
args: CheckMoneroNodeArgs,
|
||||
_: tauri::State<'_, RwLock<State>>,
|
||||
_: tauri::State<'_, State>,
|
||||
) -> Result<CheckMoneroNodeResponse, String> {
|
||||
args.request().await.to_string_result()
|
||||
}
|
||||
|
|
@ -274,7 +285,7 @@ async fn check_monero_node(
|
|||
#[tauri::command]
|
||||
async fn check_electrum_node(
|
||||
args: CheckElectrumNodeArgs,
|
||||
_: tauri::State<'_, RwLock<State>>,
|
||||
_: tauri::State<'_, State>,
|
||||
) -> Result<CheckElectrumNodeResponse, String> {
|
||||
args.request().await.to_string_result()
|
||||
}
|
||||
|
|
@ -282,7 +293,7 @@ async fn check_electrum_node(
|
|||
#[tauri::command]
|
||||
async fn check_seed(
|
||||
args: CheckSeedArgs,
|
||||
_: tauri::State<'_, RwLock<State>>,
|
||||
_: tauri::State<'_, State>,
|
||||
) -> Result<CheckSeedResponse, String> {
|
||||
args.request().await.to_string_result()
|
||||
}
|
||||
|
|
@ -291,10 +302,7 @@ async fn check_seed(
|
|||
// This is independent of the context to ensure the user can open the directory even if the context cannot
|
||||
// be initialized (for troubleshooting purposes)
|
||||
#[tauri::command]
|
||||
async fn get_data_dir(
|
||||
args: GetDataDirArgs,
|
||||
_: tauri::State<'_, RwLock<State>>,
|
||||
) -> Result<String, String> {
|
||||
async fn get_data_dir(args: GetDataDirArgs, _: tauri::State<'_, State>) -> Result<String, String> {
|
||||
Ok(data::data_dir_from(None, args.is_testnet)
|
||||
.to_string_result()?
|
||||
.to_string_lossy()
|
||||
|
|
@ -349,26 +357,46 @@ async fn save_txt_files(
|
|||
#[tauri::command]
|
||||
async fn resolve_approval_request(
|
||||
args: ResolveApprovalArgs,
|
||||
state: tauri::State<'_, RwLock<State>>,
|
||||
state: tauri::State<'_, State>,
|
||||
) -> Result<(), String> {
|
||||
println!("Resolving approval request");
|
||||
let lock = state.read().await;
|
||||
let request_id = args
|
||||
.request_id
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid request ID '{}': {}", args.request_id, e))?;
|
||||
|
||||
lock.handle
|
||||
.resolve_approval(args.request_id.parse().unwrap(), args.accept)
|
||||
state
|
||||
.handle
|
||||
.resolve_approval(request_id, args.accept)
|
||||
.await
|
||||
.to_string_result()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn reject_approval_request(
|
||||
args: RejectApprovalArgs,
|
||||
state: tauri::State<'_, State>,
|
||||
) -> Result<RejectApprovalResponse, String> {
|
||||
let request_id = args
|
||||
.request_id
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid request ID '{}': {}", args.request_id, e))?;
|
||||
|
||||
state
|
||||
.handle
|
||||
.reject_approval(request_id)
|
||||
.await
|
||||
.to_string_result()?;
|
||||
|
||||
Ok(RejectApprovalResponse { success: true })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_pending_approvals(
|
||||
state: tauri::State<'_, RwLock<State>>,
|
||||
state: tauri::State<'_, State>,
|
||||
) -> Result<GetPendingApprovalsResponse, String> {
|
||||
let approvals = state
|
||||
.read()
|
||||
.await
|
||||
.handle
|
||||
.get_pending_approvals()
|
||||
.await
|
||||
|
|
@ -382,41 +410,21 @@ async fn get_pending_approvals(
|
|||
async fn initialize_context(
|
||||
settings: TauriSettings,
|
||||
testnet: bool,
|
||||
state: tauri::State<'_, RwLock<State>>,
|
||||
state: tauri::State<'_, State>,
|
||||
) -> Result<(), String> {
|
||||
// When the app crashes, the monero-wallet-rpc process may not be killed
|
||||
// This can lead to issues when the app is restarted
|
||||
// because the monero-wallet-rpc has a lock on the wallet
|
||||
// this will prevent the newly spawned instance from opening the wallet
|
||||
// To fix this, we kill any running monero-wallet-rpc processes
|
||||
let sys = sysinfo::System::new_with_specifics(
|
||||
sysinfo::RefreshKind::new().with_processes(sysinfo::ProcessRefreshKind::new()),
|
||||
);
|
||||
// Lock at the beginning - fail immediately if already locked
|
||||
let mut context_lock = state
|
||||
.context
|
||||
.try_write()
|
||||
.map_err(|_| "Context is already being initialized".to_string())?;
|
||||
|
||||
for (pid, process) in sys.processes() {
|
||||
if process
|
||||
.name()
|
||||
.to_string_lossy()
|
||||
.starts_with("monero-wallet-rpc")
|
||||
{
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
println!("Killing monero-wallet-rpc process with pid: {}", pid);
|
||||
process.kill();
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
println!("Would kill monero-wallet-rpc process with pid: {}", pid);
|
||||
}
|
||||
// Fail if the context is already initialized
|
||||
if context_lock.is_some() {
|
||||
return Err("Context is already initialized".to_string());
|
||||
}
|
||||
|
||||
// Get app handle and create a Tauri handle
|
||||
let tauri_handle = state
|
||||
.try_read()
|
||||
.context("Context is already being initialized")
|
||||
.to_string_result()?
|
||||
.handle
|
||||
.clone();
|
||||
// Get tauri handle from the state
|
||||
let tauri_handle = state.handle.clone();
|
||||
|
||||
// Notify frontend that the context is being initialized
|
||||
tauri_handle.emit_context_init_progress_event(TauriContextStatusEvent::Initializing);
|
||||
|
|
@ -436,11 +444,7 @@ async fn initialize_context(
|
|||
|
||||
match context_result {
|
||||
Ok(context_instance) => {
|
||||
state
|
||||
.try_write()
|
||||
.context("Context is already being initialized")
|
||||
.to_string_result()?
|
||||
.set_context(Arc::new(context_instance));
|
||||
*context_lock = Some(Arc::new(context_instance));
|
||||
|
||||
tracing::info!("Context initialized");
|
||||
|
||||
|
|
|
|||
|
|
@ -4,21 +4,21 @@ version = "0.1.0"
|
|||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
time = "0.3"
|
||||
swap-serde = { path = "../swap-serde" }
|
||||
bitcoin = { workspace = true }
|
||||
monero = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
swap-fs = { path = "../swap-fs" }
|
||||
dialoguer = "0.11"
|
||||
bitcoin = { workspace = true }
|
||||
config = { version = "0.14", default-features = false, features = ["toml"] }
|
||||
dialoguer = "0.11"
|
||||
libp2p = { workspace = true, features = ["serde"] }
|
||||
thiserror = { workspace = true }
|
||||
monero = { 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"
|
||||
tracing = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
use crate::env::{Mainnet, Testnet};
|
||||
use swap_fs::{ensure_directory_exists, system_config_dir, system_data_dir};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use config::ConfigError;
|
||||
use dialoguer::theme::ColorfulTheme;
|
||||
|
|
@ -12,6 +11,7 @@ use std::ffi::OsStr;
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use swap_fs::{ensure_directory_exists, system_config_dir, system_data_dir};
|
||||
use url::Url;
|
||||
|
||||
pub trait GetDefaults {
|
||||
|
|
@ -130,9 +130,15 @@ pub struct Data {
|
|||
pub struct Network {
|
||||
#[serde(deserialize_with = "swap_serde::libp2p::multiaddresses::deserialize")]
|
||||
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>,
|
||||
#[serde(default, deserialize_with = "swap_serde::libp2p::multiaddresses::deserialize")]
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "swap_serde::libp2p::multiaddresses::deserialize"
|
||||
)]
|
||||
pub external_addresses: Vec<Multiaddr>,
|
||||
}
|
||||
|
||||
|
|
@ -259,7 +265,7 @@ pub fn query_user_for_initial_config(testnet: bool) -> Result<Config> {
|
|||
.to_string(),
|
||||
)
|
||||
.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())
|
||||
.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!();
|
||||
|
||||
Ok(Config {
|
||||
data: Data { dir: data_dir },
|
||||
data: Data { dir: data_dir},
|
||||
network: Network {
|
||||
listen: listen_addresses,
|
||||
rendezvous_point: rendezvous_points, // keeping the singular key name for backcompat
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use crate::config::Config as AsbConfig;
|
||||
use serde::Serialize;
|
||||
use std::cmp::max;
|
||||
use std::time::Duration;
|
||||
use time::ext::NumericalStdDuration;
|
||||
use crate::config::Config as AsbConfig;
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Config {
|
||||
|
|
@ -136,8 +136,6 @@ pub fn new(is_testnet: bool, asb_config: &AsbConfig) -> Config {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
pub mod config;
|
||||
pub mod env;
|
||||
pub mod config;
|
||||
|
|
@ -20,6 +20,17 @@ pub fn system_data_dir() -> Result<PathBuf> {
|
|||
.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> {
|
||||
if let Some(path) = file.parent() {
|
||||
if !path.exists() {
|
||||
|
|
|
|||
|
|
@ -4,15 +4,15 @@ version = "0.1.0"
|
|||
edition = "2024"
|
||||
|
||||
[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 }
|
||||
bitcoin = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
libp2p = { workspace = true }
|
||||
monero = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use bitcoin::Network;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use bitcoin::{Network};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(remote = "Network")]
|
||||
|
|
@ -73,4 +73,4 @@ pub mod address_serde {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,4 +37,4 @@ pub mod urls {
|
|||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
pub mod monero;
|
||||
pub mod bitcoin;
|
||||
pub mod electrum;
|
||||
pub mod libp2p;
|
||||
pub mod electrum;
|
||||
pub mod monero;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ pub mod multiaddresses {
|
|||
use serde::de::Unexpected;
|
||||
use serde::{de, Deserialize, Deserializer};
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Multiaddr>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
|
|
@ -24,17 +24,17 @@ pub mod multiaddresses {
|
|||
.map(|v| {
|
||||
if let Value::String(s) = v {
|
||||
s.trim().parse().map_err(de::Error::custom)
|
||||
} else {
|
||||
Err(de::Error::custom("expected a string"))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(list?)
|
||||
}
|
||||
value => Err(de::Error::invalid_type(
|
||||
Unexpected::Other(&value.to_string()),
|
||||
&"a string or array",
|
||||
)),
|
||||
} else {
|
||||
Err(de::Error::custom("expected a string"))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(list?)
|
||||
}
|
||||
value => Err(de::Error::invalid_type(
|
||||
Unexpected::Other(&value.to_string()),
|
||||
&"a string or array",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use monero::{Network, Amount};
|
||||
use monero::{Amount, Network};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
|
|
@ -11,6 +11,7 @@ pub enum network {
|
|||
}
|
||||
|
||||
pub mod private_key {
|
||||
use hex;
|
||||
use monero::consensus::{Decodable, Encodable};
|
||||
use monero::PrivateKey;
|
||||
use serde::de::Visitor;
|
||||
|
|
@ -18,7 +19,6 @@ pub mod private_key {
|
|||
use serde::{de, Deserializer, Serializer};
|
||||
use std::fmt;
|
||||
use std::io::Cursor;
|
||||
use hex;
|
||||
|
||||
struct BytesVisitor;
|
||||
|
||||
|
|
@ -143,4 +143,4 @@ pub mod address {
|
|||
};
|
||||
validate(address, expected_network)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@
|
|||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
"nullable": [false]
|
||||
},
|
||||
"hash": "081c729a0f1ad6e4ff3e13d6702c946bc4d37d50f40670b4f51d2efcce595aa6"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@
|
|||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
"nullable": [true]
|
||||
},
|
||||
"hash": "0d465a17ebbb5761421def759c73cad023c30705d5b41a1399ef79d8d2571d7c"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@
|
|||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
"nullable": [true]
|
||||
},
|
||||
"hash": "1f332be08a5426f3fbcadea4e755d82ff1cdc2690eb464ccc607d3a613fa76a1"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,10 +17,7 @@
|
|||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true
|
||||
]
|
||||
"nullable": [true, true]
|
||||
},
|
||||
"hash": "5cc61dd0315571bc198401a354cd9431ee68360941f341386cbacf44ea598de8"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,10 +17,7 @@
|
|||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
"nullable": [false, false]
|
||||
},
|
||||
"hash": "6130b6cdd184181f890964eb460741f5cf23b5237fb676faed009106627a4ca6"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@
|
|||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
"nullable": [false]
|
||||
},
|
||||
"hash": "88f761a4f7a0429cad1df0b1bebb1c0a27b2a45656549b23076d7542cfa21ecf"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@
|
|||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
"nullable": [false]
|
||||
},
|
||||
"hash": "d78acba5eb8563826dd190e0886aa665aae3c6f1e312ee444e65df1c95afe8b2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,11 +22,7 @@
|
|||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
"nullable": [true, false, false]
|
||||
},
|
||||
"hash": "dff8b986c3dde27b8121775e48a58564fa346b038866699210a63f8a33b03f0b"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@
|
|||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
"nullable": [false]
|
||||
},
|
||||
"hash": "e05620f420f8c1022971eeb66a803323a8cf258cbebb2834e3f7cf8f812fa646"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@
|
|||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
"nullable": [false]
|
||||
},
|
||||
"hash": "e9d422daf774d099fcbde6c4cda35821da948bd86cc57798b4d8375baf0b51ae"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ swap-env = { path = "../swap-env" }
|
|||
swap-feed = { path = "../swap-feed" }
|
||||
swap-fs = { path = "../swap-fs" }
|
||||
swap-serde = { path = "../swap-serde" }
|
||||
throttle = { path = "../throttle" }
|
||||
tauri = { version = "2.0", features = ["config-json5"], optional = true, default-features = false }
|
||||
thiserror = { workspace = true }
|
||||
time = "0.3"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
use swap_env::config::GetDefaults;
|
||||
use crate::bitcoin::{bitcoin_address, Amount};
|
||||
use swap_env::env;
|
||||
use swap_env::env::GetConfig;
|
||||
use anyhow::Result;
|
||||
use bitcoin::address::NetworkUnchecked;
|
||||
use bitcoin::Address;
|
||||
|
|
@ -9,6 +6,9 @@ use serde::Serialize;
|
|||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use structopt::StructOpt;
|
||||
use swap_env::config::GetDefaults;
|
||||
use swap_env::env;
|
||||
use swap_env::env::GetConfig;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn parse_args<I, T>(raw_args: I) -> Result<Arguments>
|
||||
|
|
@ -402,7 +402,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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 raw_ars = vec![BINARY_NAME, "start"];
|
||||
|
|
@ -420,7 +422,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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 raw_ars = vec![BINARY_NAME, "history"];
|
||||
|
|
@ -440,7 +444,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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 raw_ars = vec![BINARY_NAME, "balance"];
|
||||
|
|
@ -458,7 +464,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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 raw_ars = vec![
|
||||
BINARY_NAME,
|
||||
|
|
@ -484,7 +492,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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 raw_ars = vec![
|
||||
|
|
@ -510,7 +520,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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 raw_ars = vec![
|
||||
|
|
@ -536,7 +548,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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 raw_ars = vec![
|
||||
|
|
@ -562,7 +576,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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 raw_ars = vec![
|
||||
|
|
@ -588,7 +604,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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 raw_ars = vec![BINARY_NAME, "--testnet", "start"];
|
||||
|
|
@ -606,7 +624,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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 raw_ars = vec![BINARY_NAME, "--testnet", "history"];
|
||||
|
|
@ -626,7 +646,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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 raw_ars = vec![BINARY_NAME, "--testnet", "balance"];
|
||||
|
|
@ -644,7 +666,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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 raw_ars = vec![BINARY_NAME, "--testnet", "export-monero-wallet"];
|
||||
|
|
@ -663,7 +687,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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 raw_ars = vec![
|
||||
|
|
@ -690,7 +716,9 @@ mod tests {
|
|||
}
|
||||
#[test]
|
||||
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 raw_ars = vec![
|
||||
|
|
@ -717,7 +745,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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 raw_ars = vec![
|
||||
|
|
@ -744,7 +774,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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 raw_ars = vec![
|
||||
|
|
@ -771,7 +803,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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 raw_ars = vec![
|
||||
|
|
@ -798,7 +832,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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 raw_ars = vec![BINARY_NAME, "--disable-timestamp", "start"];
|
||||
|
|
@ -816,7 +852,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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 raw_ars = vec![BINARY_NAME, "--trace", "start"];
|
||||
|
|
|
|||
|
|
@ -24,9 +24,6 @@ use std::sync::Arc;
|
|||
use structopt::clap;
|
||||
use structopt::clap::ErrorKind;
|
||||
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::common::tor::init_tor_client;
|
||||
use swap::common::tracing_util::Format;
|
||||
|
|
@ -40,6 +37,9 @@ use swap::protocol::{Database, State};
|
|||
use swap::seed::Seed;
|
||||
use swap::{bitcoin, monero};
|
||||
use swap_feed;
|
||||
use swap_env::config::{
|
||||
initial_setup, query_user_for_initial_config, read_config, Config, ConfigNotInitialized,
|
||||
};
|
||||
use tracing_subscriber::filter::LevelFilter;
|
||||
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
|
||||
.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 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;
|
||||
|
||||
println!("Seed : {seed}");
|
||||
|
|
@ -474,7 +474,9 @@ async fn init_bitcoin_wallet(
|
|||
if sync {
|
||||
wallet.sync().await?;
|
||||
} 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)
|
||||
|
|
@ -528,6 +530,7 @@ async fn init_monero_wallet(
|
|||
env_config.monero_network,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.context("Failed to initialize Monero wallets")?;
|
||||
|
|
|
|||
|
|
@ -481,7 +481,6 @@ pub struct NotThreeWitnesses(usize);
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use swap_env::env::{GetConfig, Regtest};
|
||||
use crate::monero::TransferProof;
|
||||
use crate::protocol::{alice, bob};
|
||||
use bitcoin::secp256k1;
|
||||
|
|
@ -490,6 +489,7 @@ mod tests {
|
|||
use monero::PrivateKey;
|
||||
use rand::rngs::OsRng;
|
||||
use std::matches;
|
||||
use swap_env::env::{GetConfig, Regtest};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
pub mod request;
|
||||
pub mod tauri_bindings;
|
||||
|
||||
use crate::cli::api::tauri_bindings::SeedChoice;
|
||||
use crate::cli::command::{Bitcoin, Monero};
|
||||
use crate::common::tor::init_tor_client;
|
||||
use crate::common::tracing_util::Format;
|
||||
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::protocol::Database;
|
||||
use crate::seed::Seed;
|
||||
|
|
@ -19,6 +17,8 @@ use std::fmt;
|
|||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Once};
|
||||
use swap_env::env::{Config as EnvConfig, GetConfig, Mainnet, Testnet};
|
||||
use swap_fs::system_data_dir;
|
||||
use tauri_bindings::{
|
||||
MoneroNodeConfig, TauriBackgroundProgress, TauriContextStatusEvent, TauriEmitter, TauriHandle,
|
||||
};
|
||||
|
|
@ -40,6 +40,7 @@ pub struct Config {
|
|||
seed: Option<Seed>,
|
||||
debug: bool,
|
||||
json: bool,
|
||||
log_dir: PathBuf,
|
||||
data_dir: PathBuf,
|
||||
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.
|
||||
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
|
||||
let format = if self.json { Format::Json } else { Format::Raw };
|
||||
|
|
@ -295,7 +301,7 @@ impl ContextBuilder {
|
|||
let _ = common::tracing_util::init(
|
||||
level_filter,
|
||||
format,
|
||||
data_dir.join("logs"),
|
||||
log_dir.clone(),
|
||||
self.tauri_handle.clone(),
|
||||
false,
|
||||
);
|
||||
|
|
@ -308,11 +314,97 @@ impl ContextBuilder {
|
|||
);
|
||||
});
|
||||
|
||||
// These are needed for everything else, and are blocking calls
|
||||
let env_config = env_config_from(self.is_testnet);
|
||||
let seed = &Seed::from_file_or_generate(data_dir.as_path(), self.tauri_handle.clone())
|
||||
// Start the rpc pool for the monero wallet
|
||||
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(),
|
||||
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
|
||||
.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
|
||||
let swap_lock = Arc::new(SwapLock::new());
|
||||
|
|
@ -350,8 +442,8 @@ impl ContextBuilder {
|
|||
|
||||
let wallet = init_bitcoin_wallet(
|
||||
urls,
|
||||
seed,
|
||||
data_dir,
|
||||
&seed,
|
||||
&data_dir,
|
||||
env_config,
|
||||
target_block,
|
||||
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 {
|
||||
// Don't init a tor client unless we should use it.
|
||||
if !self.tor {
|
||||
|
|
@ -437,7 +467,7 @@ impl ContextBuilder {
|
|||
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
|
||||
.inspect_err(|err| {
|
||||
tracing::warn!(%err, "Failed to create Tor client. We will continue without Tor");
|
||||
|
|
@ -447,11 +477,8 @@ impl ContextBuilder {
|
|||
Ok(maybe_tor_client)
|
||||
};
|
||||
|
||||
let (bitcoin_wallet, (monero_manager, monero_rpc_pool_handle), tor) = tokio::try_join!(
|
||||
initialize_bitcoin_wallet,
|
||||
initialize_monero_wallet,
|
||||
initialize_tor_client,
|
||||
)?;
|
||||
let (bitcoin_wallet, tor) =
|
||||
tokio::try_join!(initialize_bitcoin_wallet, initialize_tor_client,)?;
|
||||
|
||||
// If we have a bitcoin wallet and a tauri handle, we start a background task
|
||||
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 {
|
||||
db,
|
||||
bitcoin_wallet,
|
||||
|
|
@ -480,14 +505,17 @@ impl ContextBuilder {
|
|||
json: self.json,
|
||||
is_testnet: self.is_testnet,
|
||||
data_dir: data_dir.clone(),
|
||||
log_dir: log_dir.clone(),
|
||||
},
|
||||
swap_lock,
|
||||
swap_lock,
|
||||
tasks,
|
||||
tauri_handle: self.tauri_handle,
|
||||
tor_client: tor,
|
||||
monero_rpc_pool_handle,
|
||||
};
|
||||
|
||||
tauri_handle.emit_context_init_progress_event(TauriContextStatusEvent::Available);
|
||||
|
||||
Ok(context)
|
||||
}
|
||||
}
|
||||
|
|
@ -525,6 +553,15 @@ impl Context {
|
|||
|
||||
pub fn cleanup(&self) -> Result<()> {
|
||||
// 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(())
|
||||
}
|
||||
|
|
@ -575,56 +612,227 @@ async fn init_bitcoin_wallet(
|
|||
Ok(wallet)
|
||||
}
|
||||
|
||||
async fn init_monero_wallet(
|
||||
data_dir: &Path,
|
||||
monero_daemon_address: String,
|
||||
async fn request_and_open_monero_wallet_legacy(
|
||||
data_dir: &PathBuf,
|
||||
env_config: EnvConfig,
|
||||
tauri_handle: Option<TauriHandle>,
|
||||
) -> Result<Arc<Wallets>> {
|
||||
let network = env_config.monero_network;
|
||||
let wallet_dir = data_dir.join("monero").join("monero-data");
|
||||
daemon: &monero_sys::Daemon,
|
||||
) -> Result<monero_sys::WalletHandle, Error> {
|
||||
let wallet_path = data_dir.join("swap-tool-blockchain-monitoring-wallet");
|
||||
|
||||
let daemon = monero_sys::Daemon {
|
||||
address: monero_daemon_address,
|
||||
ssl: false,
|
||||
};
|
||||
|
||||
// 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,
|
||||
let wallet = monero::Wallet::open_or_create(
|
||||
wallet_path.display().to_string(),
|
||||
daemon.clone(),
|
||||
env_config.monero_network,
|
||||
true,
|
||||
)
|
||||
.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 {
|
||||
|
|
@ -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 {
|
||||
if testnet {
|
||||
Testnet::get_config()
|
||||
|
|
@ -657,6 +875,7 @@ fn env_config_from(testnet: bool) -> EnvConfig {
|
|||
impl Config {
|
||||
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 log_dir = data_dir.join("logs"); // not used in production
|
||||
|
||||
Self {
|
||||
namespace: XmrBtcNamespace::from_is_testnet(false),
|
||||
|
|
@ -666,6 +885,7 @@ impl Config {
|
|||
json: false,
|
||||
is_testnet: false,
|
||||
data_dir,
|
||||
log_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -707,7 +927,8 @@ pub mod api_test {
|
|||
json: bool,
|
||||
) -> Self {
|
||||
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
|
||||
.unwrap();
|
||||
let env_config = env_config_from(is_testnet);
|
||||
|
|
@ -720,6 +941,7 @@ pub mod api_test {
|
|||
json,
|
||||
is_testnet,
|
||||
data_dir,
|
||||
log_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
use super::tauri_bindings::TauriHandle;
|
||||
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::list_sellers::{list_sellers_init, QuoteWithAddress, UnreachableSeller};
|
||||
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::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
|
|
@ -417,7 +421,7 @@ impl Request for GetLogsArgs {
|
|||
type Response = GetLogsResponse;
|
||||
|
||||
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?;
|
||||
|
||||
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]
|
||||
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
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))]
|
||||
pub async fn suspend_current_swap(context: Arc<Context>) -> Result<SuspendCurrentSwapResponse> {
|
||||
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 })
|
||||
}
|
||||
|
||||
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(
|
||||
rendezvous_points: Vec<Multiaddr>,
|
||||
namespace: XmrBtcNamespace,
|
||||
|
|
@ -1638,6 +1927,18 @@ pub struct ResolveApprovalResponse {
|
|||
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]
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
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]
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct GetPendingApprovalsResponse {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
use super::request::BalanceResponse;
|
||||
use crate::bitcoin;
|
||||
use crate::cli::api::request::{
|
||||
GetMoneroBalanceResponse, GetMoneroHistoryResponse, GetMoneroSyncProgressResponse,
|
||||
};
|
||||
use crate::cli::list_sellers::QuoteWithAddress;
|
||||
use crate::monero::MoneroAddressPool;
|
||||
use crate::{bitcoin::ExpiredTimelocks, monero, network::quote::BidQuote};
|
||||
|
|
@ -31,6 +34,16 @@ pub enum TauriEvent {
|
|||
Approval(ApprovalRequest),
|
||||
BackgroundProgress(TauriBackgroundProgressWrapper),
|
||||
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";
|
||||
|
|
@ -62,12 +75,42 @@ pub struct SelectMakerDetails {
|
|||
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]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "content")]
|
||||
pub enum SeedChoice {
|
||||
RandomSeed,
|
||||
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]
|
||||
|
|
@ -90,8 +133,13 @@ pub enum ApprovalRequestType {
|
|||
/// Contains available makers and swap details.
|
||||
SelectMaker(SelectMakerDetails),
|
||||
/// Request seed selection from user.
|
||||
/// User can choose between random seed or provide their own.
|
||||
SeedSelection,
|
||||
/// User can choose between random seed, provide their own, or select wallet file.
|
||||
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]
|
||||
|
|
@ -297,9 +345,9 @@ impl TauriHandle {
|
|||
) -> Result<()> {
|
||||
#[cfg(not(feature = "tauri"))]
|
||||
{
|
||||
return Err(anyhow!(
|
||||
Err(anyhow!(
|
||||
"Cannot resolve approval: Tauri feature not enabled."
|
||||
));
|
||||
))
|
||||
}
|
||||
|
||||
#[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"))]
|
||||
{
|
||||
return Ok(Vec::new());
|
||||
Err(anyhow!(
|
||||
"Cannot reject approval: Tauri feature not enabled."
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let pending_map = self
|
||||
let mut pending_map = self
|
||||
.0
|
||||
.pending_approvals
|
||||
.lock()
|
||||
.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
|
||||
.values()
|
||||
.map(|pending| pending.request.clone())
|
||||
.collect();
|
||||
// Emit the rejection event
|
||||
let mut approval = pending.request.clone();
|
||||
approval.request_status = RequestStatus::Rejected;
|
||||
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 {
|
||||
ApprovalRequestType::LockBitcoin(..) => write!(f, "LockBitcoin()"),
|
||||
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_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_unified_event(&self, event: TauriEvent) {
|
||||
|
|
@ -468,7 +537,22 @@ impl TauriEmitter for TauriHandle {
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -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>(
|
||||
&self,
|
||||
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
|
||||
///
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::bitcoin;
|
||||
use crate::monero::{Scalar, TransferProof};
|
||||
use crate::network::cooperative_xmr_redeem_after_punish::CooperativeXmrRedeemRejectReason;
|
||||
use crate::network::quote::BidQuote;
|
||||
|
|
@ -7,8 +8,6 @@ use crate::network::{
|
|||
cooperative_xmr_redeem_after_punish, encrypted_signature, quote, redial, transfer_proof,
|
||||
};
|
||||
use crate::protocol::bob::State2;
|
||||
use crate::bitcoin;
|
||||
use swap_env::env;
|
||||
use anyhow::{anyhow, Error, Result};
|
||||
use libp2p::request_response::{
|
||||
InboundFailure, InboundRequestId, OutboundFailure, OutboundRequestId, ResponseChannel,
|
||||
|
|
@ -17,6 +16,7 @@ use libp2p::swarm::NetworkBehaviour;
|
|||
use libp2p::{identify, identity, ping, PeerId};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use swap_env::env;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum OutEvent {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue