diff --git a/bin/printversion.ps1 b/bin/printversion.ps1 index a3090da4..6ffc2cc2 100644 --- a/bin/printversion.ps1 +++ b/bin/printversion.ps1 @@ -1 +1 @@ -echo "VERSION=2.0.16-153" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append +echo "VERSION=2.0.17-154" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/bin/printversion.sh b/bin/printversion.sh index 09c9605c..c3b3ea9a 100755 --- a/bin/printversion.sh +++ b/bin/printversion.sh @@ -1,3 +1,3 @@ #!/bin/bash -VERSION="2.0.16-153" +VERSION="2.0.17-154" echo "VERSION=$VERSION" >> $GITHUB_ENV diff --git a/flatpak/co.zingo.pc.metainfo.xml b/flatpak/co.zingo.pc.metainfo.xml index aef0b0cb..1df056c6 100644 --- a/flatpak/co.zingo.pc.metainfo.xml +++ b/flatpak/co.zingo.pc.metainfo.xml @@ -55,6 +55,7 @@ + diff --git a/native/Cargo.lock b/native/Cargo.lock index 75561d72..bde7e216 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -1316,7 +1316,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd5f2b7218a51c827a11d22d1439b598121fac94bf9b99452e4afffe512d78c9" dependencies = [ "heck", - "indexmap 2.13.0", + "indexmap 1.9.3", "itertools 0.14.0", "proc-macro-crate", "proc-macro2", @@ -3885,7 +3885,7 @@ dependencies = [ "derive-deftly", "libc", "paste", - "thiserror 2.0.18", + "thiserror 1.0.69", ] [[package]] @@ -3910,9 +3910,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -4450,7 +4450,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.9", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] @@ -4489,9 +4489,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -6430,7 +6430,7 @@ dependencies = [ "paste", "pin-project", "rustls-pki-types", - "rustls-webpki 0.103.9", + "rustls-webpki 0.103.13", "thiserror 2.0.18", "tokio", "tokio-util", diff --git a/native/Cargo.toml b/native/Cargo.toml index 7db1bf9b..389429ab 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -66,4 +66,16 @@ windows = { version = "0.61", features = ["Security_Credentials_UI"] } cc = "1" [profile.release] -debug = 1 \ No newline at end of file +debug = 1 +# panic strategy: intentionally left at the default ("unwind"). +# +# Switching to `panic = "abort"` would shrink the MAS binary slightly and is +# tempting from a security-hardening angle, but it is incompatible with the +# `with_panic_guard` infrastructure used throughout this crate. That guard +# wraps every FFI entry point in `panic::catch_unwind`, converting transient +# panics (zingolib internal bugs, lock poisoning, etc.) into a recoverable +# `ZingolibError::Panic` returned to JS. With `abort`, those panics would +# instead terminate the wallet process. The unwind/resilience trade-off was +# made deliberately at the architecture level — do not flip this flag without +# first removing `with_panic_guard` everywhere and propagating errors via +# `Result` only. \ No newline at end of file diff --git a/native/src/lib.rs b/native/src/lib.rs index 32eaf65c..cb8ff53f 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -42,10 +42,7 @@ use zingolib::config::{ChainType, ZingoConfig, construct_lightwalletd_uri}; use zingolib::data::PollReport; use zingolib::lightclient::LightClient; use zingolib::utils::{conversion::address_from_str, conversion::txid_from_hex_encoded_str}; -use zingolib::wallet::keys::{ - WalletAddressRef, - unified::{ReceiverSelection, UnifiedKeyStore}, -}; +use zingolib::wallet::keys::unified::{ReceiverSelection, UnifiedKeyStore}; use zingolib::wallet::{LightWallet, WalletBase, WalletSettings}; use zingolib::data::receivers::Receivers; use zcash_address::ZcashAddress; @@ -101,14 +98,12 @@ fn main(mut cx: ModuleContext) -> NeonResult<()> { cx.export_function("get_spendable_balance_with_address", get_spendable_balance_with_address)?; cx.export_function("get_spendable_balance_total", get_spendable_balance_total)?; cx.export_function("set_option_wallet", set_option_wallet)?; - cx.export_function("get_option_wallet", get_option_wallet)?; cx.export_function("create_tor_client", create_tor_client)?; cx.export_function("remove_tor_client", remove_tor_client)?; cx.export_function("get_unified_addresses", get_unified_addresses)?; cx.export_function("get_transparent_addresses", get_transparent_addresses)?; cx.export_function("create_new_unified_address", create_new_unified_address)?; cx.export_function("create_new_transparent_address", create_new_transparent_address)?; - cx.export_function("check_my_addressv", check_my_address)?; cx.export_function("get_wallet_save_required", get_wallet_save_required)?; cx.export_function("set_config_wallet_to_test", set_config_wallet_to_test)?; cx.export_function("set_config_wallet_to_prod", set_config_wallet_to_prod)?; @@ -446,7 +441,11 @@ fn construct_uri_load_config( transparent_address_discovery: TransparentAddressDiscovery::minimal(), performance_level: performancetype, }, - min_confirmations: NonZeroU32::try_from(min_confirmations as u32).unwrap(), + // `min_confirmations` comes from the renderer through IPC. Reject 0 (and + // anything that casts to 0 — negatives, NaN, fractional values <1) instead + // of unwrapping, which would panic and abort the wallet process. + min_confirmations: NonZeroU32::try_from(min_confirmations as u32) + .map_err(|_| "Error: min_confirmations must be >= 1".to_string())?, }, NonZeroU32::try_from(1).expect("hard-coded integer"), wallet_name, @@ -770,6 +769,8 @@ fn check_save_error(mut cx: FunctionContext) -> JsResult { Ok(promise) } +// FFI-exposed but currently has no JS caller. Reserved for an upcoming UI +// feature; review input validation here when the JS caller is wired up. fn get_developer_donation_address(mut cx: FunctionContext) -> JsResult { let res: Result = with_panic_guard(|| { let resp = zingolib::config::DEVELOPER_DONATION_ADDRESS.to_string(); @@ -783,6 +784,8 @@ fn get_developer_donation_address(mut cx: FunctionContext) -> JsResult } } +// FFI-exposed but currently has no JS caller. Reserved for an upcoming UI +// feature; review input validation here when the JS caller is wired up. fn get_zennies_for_zingo_donation_address(mut cx: FunctionContext) -> JsResult { let res: Result = with_panic_guard(|| { let resp = zingolib::config::ZENNIES_FOR_ZINGO_DONATION_ADDRESS.to_string(); @@ -1042,6 +1045,8 @@ fn run_sync(mut cx: FunctionContext) -> JsResult { Ok(promise) } +// FFI-exposed but currently has no JS caller. Reserved for an upcoming UI +// feature; review input validation here when the JS caller is wired up. fn pause_sync(mut cx: FunctionContext) -> JsResult { let promise = cx .task(move || -> Result { @@ -1342,6 +1347,10 @@ fn parse_address(mut cx: FunctionContext) -> JsResult { Ok(promise) } +// Validates a Unified Full Viewing Key for the renderer. Called from +// AddNewWallet.doRestoreUfvkWallet before init_from_ufvk to give the user a +// specific error (invalid key / wrong network) instead of the generic failure +// that would otherwise come back from init_from_ufvk after disk writes. fn parse_ufvk(mut cx: FunctionContext) -> JsResult { let ufvk = cx.argument::(0)?.value(&mut cx); @@ -1719,21 +1728,8 @@ fn set_option_wallet(mut cx: FunctionContext) -> JsResult { Ok(promise) } -fn get_option_wallet(mut cx: FunctionContext) -> JsResult { - let promise = cx - .task(move || -> Result { - with_panic_guard(|| { - Ok("Error: unimplemented".to_string()) - }) - }) - .promise(move |mut cx, result| match result { - Ok(msg) => Ok(cx.string(msg)), - Err(err) => cx.throw_error(err.to_string()), - }); - - Ok(promise) -} - +// FFI-exposed but currently has no JS caller. Reserved for an upcoming UI +// feature; review input validation here when the JS caller is wired up. fn create_tor_client(mut cx: FunctionContext) -> JsResult { let data_dir = cx.argument::(0)?.value(&mut cx); @@ -1764,6 +1760,8 @@ fn create_tor_client(mut cx: FunctionContext) -> JsResult { Ok(promise) } +// FFI-exposed but currently has no JS caller. Reserved for an upcoming UI +// feature; review input validation here when the JS caller is wired up. fn remove_tor_client(mut cx: FunctionContext) -> JsResult { let promise = cx .task(move || -> Result { @@ -1906,93 +1904,6 @@ fn create_new_transparent_address(mut cx: FunctionContext) -> JsResult JsResult { - let address = cx.argument::(0)?.value(&mut cx); - - let promise = cx - .task(move || -> Result { - with_panic_guard(|| { - let mut guard = LIGHTCLIENT.write().map_err(|_| ZingolibError::LightclientLockPoisoned)?; - if let Some(lightclient) = &mut *guard { - Ok(RT.block_on(async move { - match lightclient - .wallet - .read() - .await - .is_address_derived_by_keys(&address) { - Ok(address_ref) => address_ref.map_or( - json::object! { "is_wallet_address" => false }, - |address_ref| match address_ref { - WalletAddressRef::Unified { - account_id, - address_index, - has_orchard, - has_sapling, - has_transparent, - encoded_address, - } => json::object! { - "is_wallet_address" => true, - "address_type" => "unified".to_string(), - "address_index" => address_index, - "account_id" => u32::from(account_id), - "has_orchard" => has_orchard, - "has_sapling" => has_sapling, - "has_transparent" => has_transparent, - "encoded_address" => encoded_address, - }, - WalletAddressRef::OrchardInternal { - account_id, - diversifier_index, - encoded_address, - } => json::object! { - "is_wallet_address" => true, - "address_type" => "orchard_internal".to_string(), - "account_id" => u32::from(account_id), - "diversifier_index" => u128::from(diversifier_index).to_string(), - "encoded_address" => encoded_address, - }, - WalletAddressRef::SaplingExternal { - account_id, - diversifier_index, - encoded_address, - } => json::object! { - "is_wallet_address" => true, - "address_type" => "sapling".to_string(), - "account_id" => u32::from(account_id), - "diversifier_index" => u128::from(diversifier_index).to_string(), - "encoded_address" => encoded_address, - }, - WalletAddressRef::Transparent { - account_id, - scope, - address_index, - encoded_address, - } => json::object! { - "is_wallet_address" => true, - "address_type" => "transparent".to_string(), - "account_id" => u32::from(account_id), - "scope" => scope.to_string(), - "address_index" => address_index.index(), - "encoded_address" => encoded_address, - }, - }, - ).pretty(2), - Err(e) => format!("Error: {e}"), - } - })) - } else { - Err(ZingolibError::LightclientNotInitialized) - } - }) - }) - .promise(move |mut cx, result| match result { - Ok(msg) => Ok(cx.string(msg)), - Err(err) => cx.throw_error(err.to_string()), - }); - - Ok(promise) -} - fn get_wallet_save_required(mut cx: FunctionContext) -> JsResult { let promise = cx .task(move || -> Result { @@ -2016,6 +1927,8 @@ fn get_wallet_save_required(mut cx: FunctionContext) -> JsResult { Ok(promise) } +// FFI-exposed but currently has no JS caller. Reserved for an upcoming UI +// feature; review input validation here when the JS caller is wired up. fn set_config_wallet_to_test(mut cx: FunctionContext) -> JsResult { let promise = cx .task(move || -> Result { @@ -2042,6 +1955,8 @@ fn set_config_wallet_to_test(mut cx: FunctionContext) -> JsResult { Ok(promise) } +// FFI-exposed but currently has no JS caller. Reserved for an upcoming UI +// feature; review input validation here when the JS caller is wired up. fn set_config_wallet_to_prod(mut cx: FunctionContext) -> JsResult { let performance_level = cx.argument::(0)?.value(&mut cx); let min_confirmations = cx.argument::(1)?.value(&mut cx); @@ -2059,8 +1974,16 @@ fn set_config_wallet_to_prod(mut cx: FunctionContext) -> JsResult { "Low" => PerformanceLevel::Low, _ => return "Error: Not a valid performance level!".to_string(), }; + // `min_confirmations` comes from the renderer through IPC. Reject 0 + // (and anything that casts to 0) instead of unwrapping — panic in this + // async block would unwind into Neon's panic guard but corrupt wallet + // state mid-write since we already hold the write lock. + let min_conf_nonzero = match NonZeroU32::try_from(min_confirmations as u32) { + Ok(n) => n, + Err(_) => return "Error: min_confirmations must be >= 1".to_string(), + }; let mut wallet = lightclient.wallet.write().await; - wallet.wallet_settings.min_confirmations = NonZeroU32::try_from(min_confirmations as u32).unwrap(); + wallet.wallet_settings.min_confirmations = min_conf_nonzero; wallet.wallet_settings.sync_config.performance_level = performancetype; wallet.save_required = true; "Successfully set config wallet to prod.".to_string() @@ -2078,6 +2001,8 @@ fn set_config_wallet_to_prod(mut cx: FunctionContext) -> JsResult { Ok(promise) } +// FFI-exposed but currently has no JS caller. Reserved for an upcoming UI +// feature; review input validation here when the JS caller is wired up. fn get_config_wallet_performance(mut cx: FunctionContext) -> JsResult { let promise = cx .task(move || -> Result { diff --git a/package.json b/package.json index 976a98c6..bb9a6401 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zingo-pc", "productName": "Zingo PC", - "version": "2.0.16", + "version": "2.0.17", "private": true, "description": "Zingo PC", "license": "MIT", @@ -40,7 +40,7 @@ "react-textarea-autosize": "^8.5.9", "typeface-roboto": "^1.1.13", "url-parse": "^1.5.10", - "uuid": "^11.1.0", + "uuid": "^11.1.1", "zcashname-sdk": "^0.8.7" }, "scripts": { @@ -218,7 +218,7 @@ "hardenedRuntime": true, "gatekeeperAssess": false, "entitlements": "./configs/entitlements.mac.inherit.plist", - "bundleVersion": "153", + "bundleVersion": "154", "extendInfo": { "ITSAppUsesNonExemptEncryption": false }, @@ -240,7 +240,7 @@ "entitlements": "./configs/entitlements.mas.plist", "entitlementsInherit": "./configs/entitlements.mas.inherit.plist", "provisioningProfile": "./configs/Zingo_PC_Profile.provisionprofile", - "bundleVersion": "153", + "bundleVersion": "154", "extendInfo": { "ITSAppUsesNonExemptEncryption": false }, diff --git a/public/electron.js b/public/electron.js index 023bcc8f..ee51a4b5 100644 --- a/public/electron.js +++ b/public/electron.js @@ -169,6 +169,13 @@ class MenuBuilder { mainWindow.webContents.send("blockexplorer"); }, }, + { + label: "ZEC Price &Source (Tor)", + accelerator: "Ctrl+T", + click: () => { + mainWindow.webContents.send("pricetor"); + }, + }, { type: "separator" }, { label: "App &Security", @@ -400,6 +407,12 @@ let proceedToClose = false; // zcash: URI received before the renderer is ready (cold start or wallet not yet loaded) let pendingZcashUri = null; +// Last sourceDir confirmed by the user through the system "Open" dialog in +// import:scan. import:apply rejects any sourceDir that doesn't exactly match — +// the renderer must not be able to fabricate this path. Resolved to canonical +// form so the comparison is path-separator and "."/".." agnostic. +let _lastScanSourceDir = null; + function handleZcashUri(uri) { if (!uri || !uri.startsWith("zcash:")) return; const win = BrowserWindow.getAllWindows()[0]; @@ -778,6 +791,17 @@ function activateBookmarkInMainProcess(bookmarkB64, wdLog) { } } +// Sets the wallet base directory in the native (Rust) side directly from main. +// Done here instead of letting the renderer call set_wallet_base_dir over IPC so +// a compromised renderer cannot redirect wallet storage to an arbitrary path. +function setWalletBaseDirInMainProcess(walletPath, wdLog) { + const native = getNative(); + if (native && typeof native.set_wallet_base_dir === "function") { + const ok = native.set_wallet_base_dir(walletPath); + wdLog(`main-process set_wallet_base_dir=${ok}`); + } +} + // ── zingolib native IPC handlers (async no-param methods) ───────────────── // These route native.node calls from the renderer through the main process. // Sync no-param methods (deinitialize, set_crypto_default_provider_to_ring, etc.) @@ -804,12 +828,10 @@ const _NATIVE_NO_PARAM_METHODS = [ "get_total_spends_to_address", "get_spendable_balance_total", "set_option_wallet", - "get_option_wallet", "remove_tor_client", "get_unified_addresses", "get_transparent_addresses", "create_new_transparent_address", - "check_my_address", "get_wallet_save_required", "set_config_wallet_to_test", "get_config_wallet_performance", @@ -865,10 +887,6 @@ ipcMain.handle("native:init_from_b64", (_e, server_uri, chain_hint, perf, min_co assertWalletName(wallet_name); return getNative().init_from_b64(server_uri, chain_hint, perf, min_conf, wallet_name); }); -ipcMain.handle("native:set_wallet_base_dir", (_e, dirPath) => getNative().set_wallet_base_dir(dirPath)); -ipcMain.handle("native:start_security_scoped_access", (_e, bookmark_b64) => - getNative().start_security_scoped_access(bookmark_b64), -); ipcMain.handle("native:get_latest_block_server", (_e, server_uri) => getNative().get_latest_block_server(server_uri)); ipcMain.handle("native:parse_address", (_e, address) => getNative().parse_address(address)); ipcMain.handle("native:parse_ufvk", (_e, ufvk) => getNative().parse_ufvk(ufvk)); @@ -889,7 +907,15 @@ ipcMain.handle("native:delete_wallet", (_e, server_uri, chain_hint, perf, min_co assertWalletName(wallet_name); return getNative().delete_wallet(server_uri, chain_hint, perf, min_conf, wallet_name); }); -ipcMain.handle("native:create_tor_client", (_e, data_dir) => getNative().create_tor_client(data_dir)); +// Tor data_dir is derived in the main process from userData, not accepted from +// the renderer. This means a compromised renderer cannot redirect Tor's working +// directory to an arbitrary filesystem path. userData is writable on all +// platforms (MAS container, non-MAS user dir) and Tor state is technical +// client data, not user wallet data — natural place to keep it. +ipcMain.handle("native:create_tor_client", () => { + const torDir = path.join(app.getPath("userData"), "tor-data"); + return getNative().create_tor_client(torDir); +}); ipcMain.handle("native:change_server", (_e, server_uri) => getNative().change_server(server_uri)); ipcMain.handle("wallet-dir:request", async () => { @@ -925,10 +951,9 @@ ipcMain.handle("wallet-dir:request", async () => { if (typeof storedBookmark === "string" && storedBookmark.length > 0) { wdLog("returning stored bookmark"); activateBookmarkInMainProcess(storedBookmark, wdLog); - return { - path: String(settings.getSync("all.walletDirPath") ?? ""), - bookmark: storedBookmark, - }; + const storedPath = String(settings.getSync("all.walletDirPath") ?? ""); + setWalletBaseDirInMainProcess(storedPath, wdLog); + return { path: storedPath }; } // First launch: info dialog → folder picker loop @@ -1045,8 +1070,9 @@ ipcMain.handle("wallet-dir:request", async () => { } settings.setSync("all.walletDirBookmark", finalBookmark); settings.setSync("all.walletDirPath", finalPath); + setWalletBaseDirInMainProcess(finalPath, wdLog); wdLog(`bookmark stored, path=${finalPath}`); - return { path: finalPath, bookmark: finalBookmark }; + return { path: finalPath }; } } catch (e) { wdLog(`ERROR: ${e}`); @@ -1231,6 +1257,10 @@ ipcMain.handle("import:scan", async () => { return { ok: false, reason: "no-data-found", sourceDir }; } + // Remember the user-confirmed path so import:apply can verify it wasn't + // swapped by the renderer. Only set on the success path — a "no-data-found" + // return does not authorize an apply. + _lastScanSourceDir = path.resolve(sourceDir); return { ok: true, sourceDir, present }; }); @@ -1242,6 +1272,14 @@ ipcMain.handle("import:apply", async (_e, { sourceDir, choices }) => { if (typeof sourceDir !== "string" || !sourceDir) return { ok: false, reason: "bad-source" }; if (!choices || typeof choices !== "object") return { ok: false, reason: "bad-choices" }; + // sourceDir MUST match what the user selected in import:scan's system dialog. + // Without this check a compromised renderer could pass an arbitrary directory + // and have its wallets.json / AddressBook.json / settings.json copied into + // userData, overwriting the user's data with attacker-supplied content. + if (_lastScanSourceDir === null || path.resolve(sourceDir) !== _lastScanSourceDir) { + return { ok: false, reason: "not-scanned" }; + } + const userData = app.getPath("userData"); if (path.resolve(sourceDir) === path.resolve(userData)) { return { ok: false, reason: "same-folder" }; @@ -1417,7 +1455,6 @@ function createWindow() { contextIsolation: true, sandbox: true, nodeIntegrationInWorker: false, - enableRemoteModule: false, preload: path.join(__dirname, "preload.js"), }, }); @@ -1763,6 +1800,10 @@ app.whenReady().then(async () => { "style-src 'self'", "img-src 'self' data:", "connect-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'none'", + "frame-src 'none'", ].join("; "); const CSP_DEVELOPMENT = [ @@ -1771,6 +1812,10 @@ app.whenReady().then(async () => { "style-src 'self' 'unsafe-inline'", "img-src 'self' data:", "connect-src 'self' http://localhost:* ws://localhost:*", + "object-src 'none'", + "base-uri 'self'", + "form-action 'none'", + "frame-src 'none'", ].join("; "); session.defaultSession.webRequest.onHeadersReceived((details, callback) => { @@ -1782,6 +1827,16 @@ app.whenReady().then(async () => { }); }); + // Deny all renderer permission requests by default. Zingo PC does not use + // camera, microphone, geolocation, notifications, MIDI, USB, clipboard-read, + // or any other web-platform permission. Explicit deny-all is defense in depth + // on top of MAS sandbox entitlements (which already restrict these at the OS + // level on the App Store build). + session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => { + callback(false); + }); + session.defaultSession.setPermissionCheckHandler(() => false); + await maybeRunDmgToMasMigration(); createWindow(); diff --git a/public/preload.js b/public/preload.js index 28a295f1..3d8c951e 100644 --- a/public/preload.js +++ b/public/preload.js @@ -28,12 +28,10 @@ const _ALL_NATIVE_METHODS = [ "get_total_spends_to_address", "get_spendable_balance_total", "set_option_wallet", - "get_option_wallet", "remove_tor_client", "get_unified_addresses", "get_transparent_addresses", "create_new_transparent_address", - "check_my_address", "get_wallet_save_required", "set_config_wallet_to_test", "get_config_wallet_performance", @@ -65,8 +63,6 @@ const _ALL_NATIVE_METHODS = [ "init_from_seed", "init_from_ufvk", "init_from_b64", - "set_wallet_base_dir", - "start_security_scoped_access", ]; const nativeForRenderer = {}; @@ -79,6 +75,7 @@ const ALLOWED_RECEIVE = new Set([ "about", "payuri", "blockexplorer", + "pricetor", "seed", "rescan", "addnewwallet", diff --git a/src/assets/img/tor-onion.svg b/src/assets/img/tor-onion.svg new file mode 100644 index 00000000..e9607067 --- /dev/null +++ b/src/assets/img/tor-onion.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/components/addNewWallet/AddNewWallet.tsx b/src/components/addNewWallet/AddNewWallet.tsx index de3035c2..e7d152f6 100644 --- a/src/components/addNewWallet/AddNewWallet.tsx +++ b/src/components/addNewWallet/AddNewWallet.tsx @@ -292,35 +292,51 @@ const AddNewWallet: React.FC = ({ const doRestoreUfvkWallet = async () => { try { - if (!ufvk.startsWith("uview")) { - // the ufvk is not correct - openErrorModal("Parsing UFVK", "The prefix of the Unified Full Viewing Key is not valid"); + // Trim user-pasted whitespace before parsing. + const ufvkInput = ufvk.trim(); + + // Validate the UFVK cryptographically via Rust (`parse_ufvk`). The native + // function decodes the key with `Ufvk::decode` and returns either an + // "Error:" string (e.g. empty input) or a JSON object with: + // { status, chain_name, address_kind, pools_available } + // This replaces the previous prefix-only validation, which accepted + // malformed keys with the right prefix and only caught them later inside + // init_from_ufvk with a generic error. + const parseResult: string = await native.parse_ufvk(ufvkInput); + if (!parseResult || parseResult.toLowerCase().startsWith("error")) { + openErrorModal("Parsing UFVK", parseResult || "Could not parse the Unified Full Viewing Key."); return; } - if ( - selectedChain === ServerChainNameEnum.mainChainName && - (ufvk.startsWith("uviewtest") || ufvk.startsWith("uviewregtest")) - ) { - // the ufvk is not correct - openErrorModal("Parsing UFVK", "The prefix of the Unified Full Viewing Key is not valid"); + let parsed: { status?: string; chain_name?: string }; + try { + parsed = JSON.parse(parseResult); + } catch { + openErrorModal("Parsing UFVK", "The Unified Full Viewing Key could not be parsed."); return; } - if (selectedChain === ServerChainNameEnum.testChainName && !ufvk.startsWith("uviewtest")) { - // the ufvk is not correct - openErrorModal("Parsing UFVK", "The prefix of the Unified Full Viewing Key is not valid"); + if (parsed.status !== "success") { + openErrorModal("Parsing UFVK", "This is not a valid Unified Full Viewing Key."); return; } - if (selectedChain === ServerChainNameEnum.regtestChainName && !ufvk.startsWith("uviewregtest")) { - // the ufvk is not correct - openErrorModal("Parsing UFVK", "The prefix of the Unified Full Viewing Key is not valid"); + const effectiveChain = selectedChain ? selectedChain : ServerChainNameEnum.mainChainName; + if (parsed.chain_name !== effectiveChain) { + const friendly = (c: string | undefined) => + c === "main" ? "mainnet" : c === "test" ? "testnet" : c === "regtest" ? "regtest" : c; + openErrorModal( + "Parsing UFVK", + `This Unified Full Viewing Key is for ${friendly(parsed.chain_name)}, ` + + `but the selected network is ${friendly(effectiveChain)}. ` + + `Switch the network to ${friendly(parsed.chain_name)} and try again.`, + ); return; } + const { next: id, nextWalletName: wallet_name } = await nextWalletName(); const result: string = await native.init_from_ufvk( - ufvk, + ufvkInput, Number(birthday), selectedServer, - selectedChain ? selectedChain : ServerChainNameEnum.mainChainName, + effectiveChain, performanceLevel, 3, wallet_name, diff --git a/src/components/addressBook/components/AddressbookItem.tsx b/src/components/addressBook/components/AddressbookItem.tsx index 89a45aba..c4a8ca26 100644 --- a/src/components/addressBook/components/AddressbookItem.tsx +++ b/src/components/addressBook/components/AddressbookItem.tsx @@ -13,8 +13,8 @@ import { ZcashURITarget } from "../../../utils/uris"; import routes from "../../../constants/routes.json"; import Utils from "../../../utils/utils"; import { ContextApp } from "../../../context/ContextAppState"; -import { clipboard } from "../../../electronBridge"; import { isZnsAlias } from "../../../utils/zns"; +import { useCopy } from "../../common/useCopy"; type AddressBookItemProps = { item: AddressBookEntryClass; @@ -29,6 +29,7 @@ const AddressBookItemInternal: React.FC = ({ item, removeA const context = useContext(ContextApp); const { readOnly, setSendTo, currentWallet } = context; const [expandAddress, setExpandAddress] = useState(false); + const { copied, copy } = useCopy(1500); // "Send To" only makes sense when the active wallet is on the same network as // the contact — sending a mainnet address from a testnet wallet would fail. @@ -54,44 +55,49 @@ const AddressBookItemInternal: React.FC = ({ item, removeA )} {!!item.address && ( -
{ - if (item.address) { - clipboard.writeText(item.address); - setExpandAddress(true); - } - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); +
+ {copied && Copied!} +
{ if (item.address) { - clipboard.writeText(item.address); + copy(item.address); setExpandAddress(true); } - } - }} - > -
- {/* ZNS aliases are short and human-readable — show them in full + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (item.address) { + copy(item.address); + setExpandAddress(true); + } + } + }} + > +
+ {/* ZNS aliases are short and human-readable — show them in full without any trimming, both collapsed and expanded. */} - {isZnsAlias(item.address) ? ( - item.address - ) : ( - <> - {!expandAddress && Utils.trimToSmall(item.address, 10)} - {expandAddress && ( - <> - {item.address.length < 80 - ? item.address - : Utils.splitStringIntoChunks(item.address, 3).map((item) =>
{item}
)} - - )} - - )} + {isZnsAlias(item.address) ? ( + item.address + ) : ( + <> + {!expandAddress && Utils.trimToSmall(item.address, 10)} + {expandAddress && ( + <> + {item.address.length < 80 + ? item.address + : Utils.splitStringIntoChunks(item.address, 3).map((item) => ( +
{item}
+ ))} + + )} + + )} +
)} diff --git a/src/components/appstate/AppState.ts b/src/components/appstate/AppState.ts index 6eee95b7..1ceb607b 100644 --- a/src/components/appstate/AppState.ts +++ b/src/components/appstate/AppState.ts @@ -85,15 +85,32 @@ export default class AppState { handleShieldButton: () => void; setAddLabel: (a: AddressBookEntryClass) => void; - // block explorer selected - blockExplorerMainnetTransaction: BlockExplorerEnum.Zcashexplorer; - blockExplorerTestnetTransaction: BlockExplorerEnum.Zcashexplorer; - blockExplorerMainnetAddress: BlockExplorerEnum.Zcashexplorer; - blockExplorerTestnetAddress: BlockExplorerEnum.Zcashexplorer; + // Current USD price per ZEC. Refreshed every 5s by RPC.getZecPrice() via + // the runTaskPromises scheduler. Lives at the top level (not inside + // InfoClass) because the periodic info-refresh rebuilds InfoClass and + // would otherwise clobber the price between cycles. 0 means "no price + // available right now" — all USD displays render `--` in that case. + zecPrice: number; + // Whether the ZEC price should be fetched through Tor. Source of truth + // for the UI; persisted in electron-settings and kept in sync by the setter + // wired up in Routes.tsx (see `setPriceWithTor` in context). + priceWithTor: boolean; + setPriceWithTor: (v: boolean) => void; + // Whether the last successful price fetch actually went via Tor. Same + // reasoning as zecPrice — kept outside InfoClass. + lastPriceViaTor: boolean; + + // block explorer selected. Type is the enum (not a literal) so a custom + // value can be assigned and the type narrows cleanly. + blockExplorerMainnetTransaction: BlockExplorerEnum; + blockExplorerTestnetTransaction: BlockExplorerEnum; + blockExplorerMainnetAddress: BlockExplorerEnum; + blockExplorerTestnetAddress: BlockExplorerEnum; blockExplorerMainnetTransactionCustom: string; blockExplorerTestnetTransactionCustom: string; blockExplorerMainnetAddressCustom: string; blockExplorerTestnetAddressCustom: string; + setBlockExplorer: (be: any) => void; constructor() { this.totalBalance = new TotalBalanceClass(); @@ -127,6 +144,10 @@ export default class AppState { this.calculateShieldFee = async () => 0; this.handleShieldButton = () => {}; this.setAddLabel = () => {}; + this.zecPrice = 0; + this.priceWithTor = false; + this.setPriceWithTor = () => {}; + this.lastPriceViaTor = false; this.blockExplorerMainnetTransaction = BlockExplorerEnum.Zcashexplorer; this.blockExplorerTestnetTransaction = BlockExplorerEnum.Zcashexplorer; this.blockExplorerMainnetAddress = BlockExplorerEnum.Zcashexplorer; @@ -135,5 +156,6 @@ export default class AppState { this.blockExplorerTestnetTransactionCustom = ""; this.blockExplorerMainnetAddressCustom = ""; this.blockExplorerTestnetAddressCustom = ""; + this.setBlockExplorer = () => {}; } } diff --git a/src/components/appstate/classes/InfoClass.ts b/src/components/appstate/classes/InfoClass.ts index 094e180b..7cc8e30d 100644 --- a/src/components/appstate/classes/InfoClass.ts +++ b/src/components/appstate/classes/InfoClass.ts @@ -8,7 +8,6 @@ export default class InfoClass { version: string; currencyName: string; solps: number; - zecPrice: number; zcashdVersion: string; walletHeight: number; error?: string; @@ -23,7 +22,6 @@ export default class InfoClass { this.zcashdVersion = ""; this.currencyName = ""; this.solps = 0; - this.zecPrice = 0; this.walletHeight = 0; this.error = error; this.zingolib = ""; diff --git a/src/components/common/useCopy.ts b/src/components/common/useCopy.ts new file mode 100644 index 00000000..c5a18620 --- /dev/null +++ b/src/components/common/useCopy.ts @@ -0,0 +1,33 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { clipboard } from "../../electronBridge"; + +// Shared copy-to-clipboard primitive used by every copy interaction in the app. +// Exposes a `copied` flag that auto-resets after `timeoutMs` so callers can show +// a transient "Copied!" indicator and disable the trigger while it's true. +// +// The timeout defaults to 3s. Pass 5000 (or longer) for explicit "Copy X" +// buttons where the user is more likely to look away after clicking; 1500 is a +// good fit for inline click-to-copy regions where the feedback should be quick. +export function useCopy(timeoutMs: number = 3000): { copied: boolean; copy: (text: string) => void } { + const [copied, setCopied] = useState(false); + const timerRef = useRef | undefined>(undefined); + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + const copy = useCallback( + (text: string) => { + if (!text) return; + clipboard.writeText(text); + setCopied(true); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), timeoutMs); + }, + [timeoutMs], + ); + + return { copied, copy }; +} diff --git a/src/components/dashboard/Dashboard.tsx b/src/components/dashboard/Dashboard.tsx index 9441144a..32f0ecd8 100644 --- a/src/components/dashboard/Dashboard.tsx +++ b/src/components/dashboard/Dashboard.tsx @@ -5,6 +5,7 @@ import Utils from "../../utils/utils"; import { BalanceBlockHighlight, BalanceBlock } from "../balanceBlock"; import { ContextApp } from "../../context/ContextAppState"; import routes from "../../constants/routes.json"; +import TorIndicator from "./TorIndicator"; import { SyncStatusScanRangePriorityEnum, SyncStatusScanRangeType, ValueTransferClass } from "../appstate"; import ScrollPaneTop from "../scrollPane/ScrollPane"; @@ -33,6 +34,9 @@ const Dashboard: React.FC = ({ navigateToHistory }) => { transparentPool, calculateShieldFee, handleShieldButton, + zecPrice, + priceWithTor, + lastPriceViaTor, } = context; const [anyPending, setAnyPending] = useState(false); @@ -69,7 +73,7 @@ const Dashboard: React.FC = ({ navigateToHistory }) => { totalBalance.totalTransparentBalance } usdValue={Utils.getZecToUsdString( - info.zecPrice, + zecPrice, totalBalance.totalOrchardBalance + totalBalance.totalSaplingBalance + totalBalance.totalTransparentBalance, @@ -81,7 +85,7 @@ const Dashboard: React.FC = ({ navigateToHistory }) => { totalBalance.confirmedTransparentBalance } usdValueConfirmed={Utils.getZecToUsdString( - info.zecPrice, + zecPrice, totalBalance.confirmedOrchardBalance + totalBalance.confirmedSaplingBalance + totalBalance.confirmedTransparentBalance, @@ -91,30 +95,30 @@ const Dashboard: React.FC = ({ navigateToHistory }) => { )} {saplingPool && ( )} {transparentPool && ( )}
@@ -382,7 +386,15 @@ const Dashboard: React.FC = ({ navigateToHistory }) => { {info.currencyName === "ZEC" && ( - + + + {`USD ${zecPrice.toFixed(2)}`} + + } + /> )}
diff --git a/src/components/dashboard/TorIndicator.tsx b/src/components/dashboard/TorIndicator.tsx new file mode 100644 index 00000000..35b7221b --- /dev/null +++ b/src/components/dashboard/TorIndicator.tsx @@ -0,0 +1,60 @@ +import React from "react"; + +import torOnion from "../../assets/img/tor-onion.svg"; + +type Props = { + /** User preference: should the price be fetched via Tor? */ + intent: boolean; + /** Reality: was the last successful price fetch actually via Tor? */ + reality: boolean; + /** Optional size in px (default 16). */ + size?: number; +}; + +/** + * Small Tor onion next to the USD price on the dashboard. Three visual states: + * + * intent=false, reality=* → not rendered (no indicator at all) + * intent=true, reality=true → solid onion, tooltip "Price fetched via Tor" + * intent=true, reality=false → onion at reduced opacity with red dot, + * tooltip "Tor configured but failed — fell back to HTTP" + * + * The intent is read by the parent from settings; reality is the + * `lastPriceViaTor` field set by RPC.getZecPrice() on each successful fetch. + */ +const TorIndicator: React.FC = ({ intent, reality, size = 16 }) => { + if (!intent) return null; + + const succeeded = reality; + const tooltip = succeeded ? "Price fetched via Tor" : "Tor configured but failed — fell back to the HTTPS API"; + + return ( + + Tor + {!succeeded && ( + + ); +}; + +export default TorIndicator; diff --git a/src/components/detailLine/DetailLine.tsx b/src/components/detailLine/DetailLine.tsx index 357b4e73..95bd8b7c 100644 --- a/src/components/detailLine/DetailLine.tsx +++ b/src/components/detailLine/DetailLine.tsx @@ -4,7 +4,10 @@ import styles from "./DetailLine.module.css"; type DetailLineProps = { label: string; - value: string; + // ReactNode so callers can interleave inline icons / badges with the text + // (e.g. a Tor onion next to the ZEC Price line). String values still work + // unchanged because string is assignable to ReactNode. + value: React.ReactNode; }; const DetailLine = ({ label, value }: DetailLineProps) => { diff --git a/src/components/history/History.tsx b/src/components/history/History.tsx index 96d1a5dd..4df342f6 100644 --- a/src/components/history/History.tsx +++ b/src/components/history/History.tsx @@ -25,6 +25,7 @@ const History: React.FC = () => { transparentPool, calculateShieldFee, handleShieldButton, + zecPrice, } = context; const [valueTransferDetail, setValueTransferDetail] = useState(undefined); @@ -110,39 +111,39 @@ const History: React.FC = () => { {orchardPool && ( )} {saplingPool && ( )} {transparentPool && ( )} diff --git a/src/components/history/components/VtItemBlock.test.tsx b/src/components/history/components/VtItemBlock.test.tsx index 462956d3..406b24d6 100644 --- a/src/components/history/components/VtItemBlock.test.tsx +++ b/src/components/history/components/VtItemBlock.test.tsx @@ -48,15 +48,16 @@ describe("VtItemBlock", () => { expect(screen.queryByText(/\w{3} \d{2}, \d{4}/)).not.toBeInTheDocument(); }); - it("shows the truncated address when address is present", () => { + it("shows the (truncated) address when address is present", () => { render(); - // trimToSmall(addr, 10) shows first + last chars - expect(screen.getByLabelText("Copy address")).toBeInTheDocument(); + // trimToSmall renders the address text inside the row (no per-element copy + // button — the whole row is clickable to open VtModal). + expect(screen.getByText(/u1short/)).toBeInTheDocument(); }); - it("shows the txid copy button when there is no address", () => { + it("falls back to the (truncated) txid when there is no address", () => { render(); - expect(screen.getByLabelText("Copy transaction ID")).toBeInTheDocument(); + expect(screen.getByText(/abc123txid/)).toBeInTheDocument(); }); it("shows address book label when address is in the map", () => { @@ -116,47 +117,6 @@ describe("VtItemBlock", () => { expect(screen.getByText("USD 30.00 / ZEC")).toBeInTheDocument(); }); - it("expands the address inline when clicked (short address path)", () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { clipboard } = require("../../../electronBridge"); - render(); - fireEvent.click(screen.getByLabelText("Copy address")); - expect(clipboard.writeText).toHaveBeenCalledWith("u1tinyaddress"); - }); - - it("expands a long address into chunks when clicked", () => { - const longAddr = "u1" + "x".repeat(100); - render(); - fireEvent.click(screen.getByLabelText("Copy address")); - // chunked rendering uses splitStringIntoChunks(addr, 3) — at least one chunk visible - expect(screen.getByLabelText("Copy address")).toBeInTheDocument(); - }); - - it("triggers address-copy via keyboard Enter", () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { clipboard } = require("../../../electronBridge"); - render(); - fireEvent.keyDown(screen.getByLabelText("Copy address"), { key: "Enter" }); - expect(clipboard.writeText).toHaveBeenCalled(); - }); - - it("expands the txid inline when address is missing (long txid)", () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { clipboard } = require("../../../electronBridge"); - const longTxid = "a".repeat(120); - render(); - fireEvent.click(screen.getByLabelText("Copy transaction ID")); - expect(clipboard.writeText).toHaveBeenCalledWith(longTxid); - }); - - it("triggers txid-copy via keyboard space", () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { clipboard } = require("../../../electronBridge"); - render(); - fireEvent.keyDown(screen.getByLabelText("Copy transaction ID"), { key: " " }); - expect(clipboard.writeText).toHaveBeenCalled(); - }); - it("opens the modal via keyboard Enter on the outer button", () => { const setModalIsOpen = jest.fn(); render(); diff --git a/src/components/history/components/VtItemBlock.tsx b/src/components/history/components/VtItemBlock.tsx index 74cc74d7..95d80ff5 100644 --- a/src/components/history/components/VtItemBlock.tsx +++ b/src/components/history/components/VtItemBlock.tsx @@ -1,10 +1,9 @@ -import React, { useState } from "react"; +import React from "react"; import dateformat from "dateformat"; import styles from "../History.module.css"; import cstyles from "../../common/Common.module.css"; import { ValueTransferClass, ValueTransferKindEnum, ValueTransferStatusEnum } from "../../appstate"; import Utils from "../../../utils/utils"; -import { clipboard } from "../../../electronBridge"; type VtItemBlockProps = { index: number; @@ -27,9 +26,6 @@ const VtItemBlock: React.FC = ({ addressBookMap, previousLineWithSameTxid, }) => { - const [expandAddress, setExpandAddress] = useState(false); - const [expandTxid, setExpandTxid] = useState(false); - const txDate: Date = new Date(vt.time * 1000); const datePart: string = dateformat(txDate, "mmm dd, yyyy"); const timePart: string = dateformat(txDate, "hh:MM tt"); @@ -43,11 +39,11 @@ const VtItemBlock: React.FC = ({ const { bigPart, smallPart }: { bigPart: string; smallPart: string } = Utils.splitZecAmountIntoBigSmall(amount); + // Per-transaction ZEC price snapshot. Unified through `getZecRateString` so + // the missing-price fallback (`USD --`) matches the rest of the app — no + // more stray `USD -- / ZEC` variants. const price: number = vt.zec_price ? vt.zec_price : 0; - let priceString: string = ""; - if (price && currencyName === "ZEC") { - priceString = `USD ${price.toFixed(2)} / ZEC`; - } + const priceString: string = currencyName === "ZEC" ? Utils.getZecRateString(price) : ""; //if (index === 0) { // vt.status = ValueTransferStatusEnum.failed; @@ -134,77 +130,12 @@ const VtItemBlock: React.FC = ({ {label} )} - {!!address ? ( -
-
{ - if (address) { - clipboard.writeText(address); - setExpandAddress(true); - } - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - if (address) { - clipboard.writeText(address); - setExpandAddress(true); - } - } - }} - > -
- {!address && "Unknown"} - {!expandAddress && !!address && Utils.trimToSmall(address, 10)} - {expandAddress && !!address && ( - <> - {address.length < 80 - ? address - : Utils.splitStringIntoChunks(address, 3).map((item) =>
{item}
)} - - )} -
-
-
- ) : ( -
{ - if (txid) { - clipboard.writeText(txid); - setExpandTxid(true); - } - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - if (txid) { - clipboard.writeText(txid); - setExpandTxid(true); - } - } - }} - > -
- {!txid && "-"} - {!expandTxid && !!txid && Utils.trimToSmall(txid, 10)} - {expandTxid && !!txid && ( - <> - {txid.length < 80 - ? txid - : Utils.splitStringIntoChunks(txid, 3).map((item) =>
{item}
)} - - )} -
-
- )} + {/* The whole list row (txbox above) is already clickable and opens + VtModal, which shows the full address/txid and offers copy + there. So this is display-only — no click-to-copy here. */} +
+ {address ? Utils.trimToSmall(address, 10) : txid ? Utils.trimToSmall(txid, 10) : "-"} +
= ({ const [valueTransferIndex, setValueTransferIndex] = useState(index); const [expandAddress, setExpandAddress] = useState(false); const [expandTxid, setExpandTxid] = useState(false); + const { copied: addressCopied, copy: copyAddress } = useCopy(1500); + const { copied: txidCopied, copy: copyTxid } = useCopy(1500); const [showNavigator, setShowNavigator] = useState(true); const isTheFirstMount = useRef(true); @@ -195,8 +198,10 @@ const VtModalInternal: React.FC = ({ memos = valueTransfer.memos && valueTransfer.memos.length > 0 ? valueTransfer.memos : []; pool = valueTransfer.pool ? valueTransfer.pool : ""; price = valueTransfer.zec_price ? valueTransfer.zec_price : 0; - if (price && currencyName === "ZEC") { - priceString = `USD ${price.toFixed(2)} / ZEC`; + // Unified through `getZecRateString` so the missing-price fallback + // (`USD --`) matches the rest of the app. + if (currencyName === "ZEC") { + priceString = Utils.getZecRateString(price); } } @@ -465,12 +470,19 @@ const VtModalInternal: React.FC = ({ {!!txid && (
-
TXID
+
+ TXID + {txidCopied && ( + + Copied! + + )} +
{ if (txid) { - clipboard.writeText(txid); + copyTxid(txid); setExpandTxid(true); } }} @@ -516,7 +528,14 @@ const VtModalInternal: React.FC = ({ {!!address && (
-
Address
+
+ Address + {addressCopied && ( + + Copied! + + )} +
{!!label && (
{label} @@ -527,7 +546,7 @@ const VtModalInternal: React.FC = ({ style={{ cursor: "pointer" }} onClick={() => { if (address) { - clipboard.writeText(address); + copyAddress(address); setExpandAddress(true); } }} diff --git a/src/components/loadingScreen/LoadingScreen.tsx b/src/components/loadingScreen/LoadingScreen.tsx index a80f4828..f2e4c2a6 100644 --- a/src/components/loadingScreen/LoadingScreen.tsx +++ b/src/components/loadingScreen/LoadingScreen.tsx @@ -46,7 +46,6 @@ type LoadingScreenProps = { setCurrentWallet: (w: WalletType | null) => void; setCurrentWalletOpenError: (e: string) => void; setFetchError: (command: string, error: string) => void; - setBlockExplorer: (be: any) => void; }; class LoadingScreen extends Component { @@ -242,10 +241,9 @@ class LoadingScreen extends Component { settings && settings.serverselection ? settings.serverselection : "", ); - // block explorer configuration - if (settings && settings.hasOwnProperty("blockexplorer")) { - this.props.setBlockExplorer(settings.blockexplorer); - } + // block explorer configuration is now loaded at app boot in Routes.tsx + // (same useEffect that calls loadSettings for auth and pricewithtor) and + // exposed via context — no per-screen wiring needed here. // to know the App is magrating to multi-wallet the settings field // `currentwalletid` must have not exists. @@ -640,15 +638,14 @@ class LoadingScreen extends Component { // On macOS MAS builds, request security-scoped access to the wallet directory // before any native wallet calls. On other platforms this returns null (no-op). try { - const walletDirResult: { path: string; bookmark: string } | null = await ipcRenderer.invoke("wallet-dir:request"); + // The handler activates the security-scoped bookmark and calls + // set_wallet_base_dir on the Rust side directly. The renderer no longer + // touches either — see electron.js wallet-dir:request. + const walletDirResult: { path: string } | null = await ipcRenderer.invoke("wallet-dir:request"); console.log( `[wallet-dir] result=${walletDirResult !== null ? "ok path=" + walletDirResult.path : "null"} isSandboxed=${isSandboxed}`, ); - if (walletDirResult !== null) { - const accessGranted = await native.start_security_scoped_access(walletDirResult.bookmark); - const baseDirSet = await native.set_wallet_base_dir(walletDirResult.path); - console.log(`[wallet-dir] start_security_scoped_access=${accessGranted} set_wallet_base_dir=${baseDirSet}`); - } else if (walletDirResult === null && isSandboxed) { + if (walletDirResult === null && isSandboxed) { // On MAS sandbox the handler only returns null if the user quit the app via the // dialog, which calls app.quit() before reaching here. If we somehow land here // it means an unexpected failure — don't silently proceed with the empty container dir. diff --git a/src/components/messages/Messages.tsx b/src/components/messages/Messages.tsx index 5dd86375..2883ea44 100644 --- a/src/components/messages/Messages.tsx +++ b/src/components/messages/Messages.tsx @@ -26,6 +26,7 @@ const Messages: React.FC = () => { transparentPool, calculateShieldFee, handleShieldButton, + zecPrice, } = context; const [valueTransferDetail, setValueTransferDetail] = useState(undefined); @@ -97,7 +98,7 @@ const Messages: React.FC = () => { totalBalance.totalOrchardBalance + totalBalance.totalSaplingBalance + totalBalance.totalTransparentBalance } usdValue={Utils.getZecToUsdString( - info.zecPrice, + zecPrice, totalBalance.totalOrchardBalance + totalBalance.totalSaplingBalance + totalBalance.totalTransparentBalance, @@ -109,7 +110,7 @@ const Messages: React.FC = () => { totalBalance.confirmedTransparentBalance } usdValueConfirmed={Utils.getZecToUsdString( - info.zecPrice, + zecPrice, totalBalance.confirmedOrchardBalance + totalBalance.confirmedSaplingBalance + totalBalance.confirmedTransparentBalance, @@ -119,30 +120,30 @@ const Messages: React.FC = () => { )} {saplingPool && ( )} {transparentPool && ( )}
diff --git a/src/components/receive/Receive.tsx b/src/components/receive/Receive.tsx index f5a2be29..d0151398 100644 --- a/src/components/receive/Receive.tsx +++ b/src/components/receive/Receive.tsx @@ -34,6 +34,7 @@ const Receive: React.FC = () => { valueTransfers, readOnly, fetchError, + zecPrice, } = context; const [uaddrs, setUaddrs] = useState([]); @@ -97,7 +98,7 @@ const Receive: React.FC = () => { totalBalance.totalOrchardBalance + totalBalance.totalSaplingBalance + totalBalance.totalTransparentBalance } usdValue={Utils.getZecToUsdString( - info.zecPrice, + zecPrice, totalBalance.totalOrchardBalance + totalBalance.totalSaplingBalance + totalBalance.totalTransparentBalance, @@ -109,7 +110,7 @@ const Receive: React.FC = () => { totalBalance.confirmedTransparentBalance } usdValueConfirmed={Utils.getZecToUsdString( - info.zecPrice, + zecPrice, totalBalance.confirmedOrchardBalance + totalBalance.confirmedSaplingBalance + totalBalance.confirmedTransparentBalance, @@ -119,30 +120,30 @@ const Receive: React.FC = () => { )} {saplingPool && ( )} {transparentPool && ( )}
diff --git a/src/components/receive/components/AddressBlock.tsx b/src/components/receive/components/AddressBlock.tsx index ada806bb..81369d75 100644 --- a/src/components/receive/components/AddressBlock.tsx +++ b/src/components/receive/components/AddressBlock.tsx @@ -13,7 +13,8 @@ import { ContextApp } from "../../../context/ContextAppState"; import { ServerChainNameEnum, TransparentAddressClass, UnifiedAddressClass, ValueTransferClass } from "../../appstate"; import RPC from "../../../rpc/rpc"; -import { clipboard, ipcRenderer, isSandboxed } from "../../../electronBridge"; +import { ipcRenderer, isSandboxed } from "../../../electronBridge"; +import { useCopy } from "../../common/useCopy"; type AddressBlockProps = { address: UnifiedAddressClass | TransparentAddressClass; @@ -46,17 +47,15 @@ const AddressBlock: React.FC = ({ } = context; const address_address = address.encoded_address; - const [copied, setCopied] = useState(false); + const { copied, copy } = useCopy(5000); const [creating, setCreating] = useState(false); const [shieldFee, setShieldFee] = useState(0); const [unifiedCreateType, setUnifiedCreateType] = useState<"o" | "z" | "oz">("o"); - const copiedTimerRef = useRef | undefined>(undefined); const creatingTimerRef = useRef | undefined>(undefined); useEffect(() => { return () => { - clearTimeout(copiedTimerRef.current); clearTimeout(creatingTimerRef.current); }; }, []); @@ -127,14 +126,14 @@ const AddressBlock: React.FC = ({
{label && ( -
+
Label
{label}
)} {type === "u" && ( -
+
Address type: {Utils.getReceivers(address as UnifiedAddressClass).join(" + ")}
@@ -142,7 +141,7 @@ const AddressBlock: React.FC = ({ )} {type === "t" && ( -
+
Address type: Transparent
)} @@ -152,11 +151,7 @@ const AddressBlock: React.FC = ({ disabled={copied} className={`${cstyles.primarybutton} ${cstyles.margintoplarge}`} type="button" - onClick={() => { - setCopied(true); - clipboard.writeText(address_address); - copiedTimerRef.current = setTimeout(() => setCopied(false), 5000); - }} + onClick={() => copy(address_address)} > {copied ? Copied! : Copy Address} diff --git a/src/components/send/Send.tsx b/src/components/send/Send.tsx index c19429e1..6d9c452e 100644 --- a/src/components/send/Send.tsx +++ b/src/components/send/Send.tsx @@ -33,6 +33,7 @@ const Send: React.FC = ({ sendTransaction, setSendPageState }) => { setSendTo, calculateShieldFee, handleShieldButton, + zecPrice, } = context; const [modalIsOpen, setModalIsOpen] = useState(false); @@ -284,7 +285,7 @@ const Send: React.FC = ({ sendTransaction, setSendPageState }) => { totalBalance.totalOrchardBalance + totalBalance.totalSaplingBalance + totalBalance.totalTransparentBalance } usdValue={Utils.getZecToUsdString( - info.zecPrice, + zecPrice, totalBalance.totalOrchardBalance + totalBalance.totalSaplingBalance + totalBalance.totalTransparentBalance, @@ -294,7 +295,7 @@ const Send: React.FC = ({ sendTransaction, setSendPageState }) => { @@ -330,7 +331,7 @@ const Send: React.FC = ({ sendTransaction, setSendPageState }) => { = ({ blockExplorerTestnetTransaction, blockExplorerMainnetTransactionCustom, blockExplorerTestnetTransactionCustom, + zecPrice, } = context; const [sendingTotal, setSendingTotal] = useState(0); @@ -346,7 +347,7 @@ const SendConfirmModal: React.FC = ({ {smallPart}
{info.currencyName === "ZEC" && ( -
{Utils.getZecToUsdString(info.zecPrice, sendingTotal)}
+
{Utils.getZecToUsdString(zecPrice, sendingTotal)}
)}
@@ -356,12 +357,13 @@ const SendConfirmModal: React.FC = ({
{[sendPageState.toaddr].map((t) => ( - + ))}
diff --git a/src/components/send/components/SendConfirmModalToAddr.test.tsx b/src/components/send/components/SendConfirmModalToAddr.test.tsx index 84702c36..4495e98a 100644 --- a/src/components/send/components/SendConfirmModalToAddr.test.tsx +++ b/src/components/send/components/SendConfirmModalToAddr.test.tsx @@ -14,39 +14,45 @@ const makeToAddr = (overrides: Partial = {}): ToAddrClass => ...overrides, }); -const info = Object.assign(new InfoClass(), { currencyName: "ZEC", zecPrice: 100 }); +const info = Object.assign(new InfoClass(), { currencyName: "ZEC" }); describe("SendConfirmModalToAddr", () => { it("renders without crashing", () => { - render(); + render(); }); it("shows the destination address when shorter than 80 chars", () => { - render(); + render(); expect(screen.getByText("u1short")).toBeInTheDocument(); }); it("splits a long address into chunks", () => { const longAddr = "u1" + "a".repeat(79); - render(); + render(); // splitStringIntoChunks(addr, 3) produces 3 divs — none equals the full address expect(screen.queryByText(longAddr)).not.toBeInTheDocument(); }); it("shows the combined memo", () => { - render(); + render( + , + ); expect(screen.getByText("Hello World")).toBeInTheDocument(); }); it("shows USD value for ZEC currency", () => { - render(); + render(); // getZecToUsdString(100, 1) = "USD 100.00" expect(screen.getByText(/USD 100\.00/)).toBeInTheDocument(); }); it("hides USD value for non-ZEC currency", () => { - const tazInfo = Object.assign(new InfoClass(), { currencyName: "TAZ", zecPrice: 0 }); - render(); + const tazInfo = Object.assign(new InfoClass(), { currencyName: "TAZ" }); + render(); expect(screen.queryByText(/USD/)).not.toBeInTheDocument(); }); }); diff --git a/src/components/send/components/SendConfirmModalToAddr.tsx b/src/components/send/components/SendConfirmModalToAddr.tsx index 93113e47..59955fc6 100644 --- a/src/components/send/components/SendConfirmModalToAddr.tsx +++ b/src/components/send/components/SendConfirmModalToAddr.tsx @@ -6,9 +6,10 @@ import cstyles from "../../common/Common.module.css"; type SendConfirmModalToAddrProps = { toaddr: ToAddrClass; info: InfoClass; + zecPrice: number; }; -const SendConfirmModalToAddr = ({ toaddr, info }: SendConfirmModalToAddrProps) => { +const SendConfirmModalToAddr = ({ toaddr, info, zecPrice }: SendConfirmModalToAddrProps) => { const { bigPart, smallPart }: { bigPart: string; smallPart: string } = Utils.splitZecAmountIntoBigSmall( toaddr.amount, ); @@ -35,7 +36,7 @@ const SendConfirmModalToAddr = ({ toaddr, info }: SendConfirmModalToAddrProps) = {smallPart}
- {info.currencyName === "ZEC" &&
{Utils.getZecToUsdString(info.zecPrice, toaddr.amount)}
} + {info.currencyName === "ZEC" &&
{Utils.getZecToUsdString(zecPrice, toaddr.amount)}
}
{memo + memoReplyTo}
diff --git a/src/components/sideBar/Sidebar.tsx b/src/components/sideBar/Sidebar.tsx index d21436f0..ae10cff6 100644 --- a/src/components/sideBar/Sidebar.tsx +++ b/src/components/sideBar/Sidebar.tsx @@ -12,16 +12,142 @@ import APP_VERSION from "../../version"; import SelectWallet from "./components/SelectWallet"; import { WalletType } from "../appstate"; import BlockExplorerModal from "./components/BlockExplorerModal"; +import PriceTorModal from "./components/PriceTorModal"; +import { useCopy } from "../common/useCopy"; import { ipcRenderer, native } from "../../electronBridge"; +// Modal content for "Wallet Seed Phrase / Viewing Key" extracted to its own +// component because the inline copy feedback for UFVK / birthday relies on +// useCopy() hooks, which cannot be called inside the event handler that opens +// the modal. As a component, the content owns its hook state and re-renders +// in place when the user clicks to copy. Seed phrase is intentionally NOT +// copyable to the clipboard — see the note in the JSX. +type SeedUfvkModalContentProps = { + seedStr: string; + ufvkStr: string; + birthday: number | undefined; +}; + +const SeedUfvkModalContent: React.FC = ({ seedStr, ufvkStr, birthday }) => { + const { copied: ufvkCopied, copy: copyUfvk } = useCopy(1500); + const { copied: birthdayCopied, copy: copyBirthday } = useCopy(1500); + const birthdayStr = String(birthday ?? ""); + + return ( +
+ {!!seedStr && ( + <> +
+ This is your wallet’s seed phrase. It can be used to recover your entire wallet. PLEASE KEEP IT SAFE! +
+ {/* Seed phrase is intentionally NOT copyable to the system clipboard: + any other process running as this user can read the clipboard, + which would expose spend authority. */} +
+ Write this seed phrase down by hand. Do not copy it to the clipboard. +
+
+
+ {seedStr} +
+
+ + )} + {!!ufvkStr && ( + <> +
+ This is your wallet’s unified full viewing key. It can be used to recover your entire wallet. +
+ PLEASE KEEP IT SAFE! +
+
+
+ Unified Full Viewing Key + {ufvkCopied && Copied!} +
+ +
copyUfvk(ufvkStr)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + copyUfvk(ufvkStr); + } + }} + > + {ufvkStr} +
+
+ + )} +
+ Birthday + {birthdayCopied && Copied!} +
+
copyBirthday(birthdayStr)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + copyBirthday(birthdayStr); + } + }} + > + {birthdayStr} +
+
+ ); +}; + type SidebarProps = { doRescan: () => void; navigateToLoadingScreenChangingWallet: () => void; - setBlockExplorer: (be: any) => void; }; -const Sidebar: React.FC = ({ doRescan, navigateToLoadingScreenChangingWallet, setBlockExplorer }) => { +const Sidebar: React.FC = ({ doRescan, navigateToLoadingScreenChangingWallet }) => { const navigate = useNavigate(); const location = useLocation(); const context = useContext(ContextApp); @@ -35,14 +161,6 @@ const Sidebar: React.FC = ({ doRescan, navigateToLoadingScreenChan currentWallet, currentWalletOpenError, wallets, - blockExplorerMainnetAddress, - blockExplorerMainnetAddressCustom, - blockExplorerMainnetTransaction, - blockExplorerMainnetTransactionCustom, - blockExplorerTestnetAddress, - blockExplorerTestnetAddressCustom, - blockExplorerTestnetTransaction, - blockExplorerTestnetTransactionCustom, } = context; const [payURIModalIsOpen, setPayURIModalIsOpen] = useState(false); @@ -50,6 +168,8 @@ const Sidebar: React.FC = ({ doRescan, navigateToLoadingScreenChan const [blockExplorerModalIsOpen, setBlockExplorerModalIsOpen] = useState(false); + const [priceTorModalIsOpen, setPriceTorModalIsOpen] = useState(false); + const currentWalletRef = useRef(null); const currentWalletOpenErrorRef = useRef(""); const walletsRef = useRef([]); @@ -199,7 +319,14 @@ const Sidebar: React.FC = ({ doRescan, navigateToLoadingScreenChan // Block Explorer Selection const blockexplorer = (_event: any) => { if (!active) return; - openBlockExplorerModal(); + setBlockExplorerModalIsOpen(true); + }; + + // ZEC Price Source (Tor on/off). The modal reads the current value + // directly from context, so we only need to open it. + const pricetor = (_event: any) => { + if (!active) return; + setPriceTorModalIsOpen(true); }; // Export Seed @@ -210,6 +337,20 @@ const Sidebar: React.FC = ({ doRescan, navigateToLoadingScreenChan return; } + // Re-authenticate before exposing seed/UFVK, even mid-session. The + // startup lock screen gates app entry but a long-running session would + // otherwise let anyone with screen access reveal the spend authority + // (seed) or the viewing key (full tx history + balance). Matches the + // pattern in SendConfirmModal.sendButton. + const allSettings = await ipcRenderer.invoke("loadSettings"); + if (allSettings?.requireDeviceAuth) { + const authResult: { success: boolean } = await ipcRenderer.invoke( + "auth:verify", + "Show seed phrase / viewing key", + ); + if (!authResult.success) return; + } + // Always fetch the UFVK — for seed wallets it's derived from the seed, and // showing it alongside the seed lets the user share view-only access without // exposing spend authority. @@ -220,54 +361,7 @@ const Sidebar: React.FC = ({ doRescan, navigateToLoadingScreenChan openErrorModal( "Wallet Seed Phrase / Viewing Key", -
- {!!seedStr && ( - <> -
- This is your wallet’s seed phrase. It can be used to recover your entire wallet. -
- PLEASE KEEP IT SAFE! -
-
-
- {seedStr} -
-
- - )} - {!!ufvkStr && ( - <> -
- This is your wallet’s unified full viewing key. It can be used to recover your entire wallet. -
- PLEASE KEEP IT SAFE! -
-
-
- {ufvkStr} -
-
- - )} -
- {"Birthday: " + birthdayRef.current} -
-
, + , ); }; @@ -306,6 +400,7 @@ const Sidebar: React.FC = ({ doRescan, navigateToLoadingScreenChan ipcRenderer.on("about", about); ipcRenderer.on("payuri", payuri); ipcRenderer.on("blockexplorer", blockexplorer); + ipcRenderer.on("pricetor", pricetor); ipcRenderer.on("seed", seed); ipcRenderer.on("rescan", rescan); ipcRenderer.on("addnewwallet", addnewwallet); @@ -317,6 +412,7 @@ const Sidebar: React.FC = ({ doRescan, navigateToLoadingScreenChan ipcRenderer.off("about", about); ipcRenderer.off("payuri", payuri); ipcRenderer.off("blockexplorer", blockexplorer); + ipcRenderer.off("pricetor", pricetor); ipcRenderer.off("seed", seed); ipcRenderer.off("rescan", rescan); ipcRenderer.off("addnewwallet", addnewwallet); @@ -340,14 +436,6 @@ const Sidebar: React.FC = ({ doRescan, navigateToLoadingScreenChan setPayURIModalIsOpen(false); }; - const openBlockExplorerModal = () => { - setBlockExplorerModalIsOpen(true); - }; - - const closeBlockExplorerModal = () => { - setBlockExplorerModalIsOpen(false); - }; - const payURI = async (uri: string) => { const errTitle: string = "URI Error"; const getErrorBody = (explain: string): ReactElement => { @@ -395,22 +483,17 @@ const Sidebar: React.FC = ({ doRescan, navigateToLoadingScreenChan /> setBlockExplorerModalIsOpen(false)} modalTitle="Select Block Explorer" /> + setPriceTorModalIsOpen(false)} + modalTitle="ZEC Price Source" + /> +
diff --git a/src/components/sideBar/components/BlockExplorerModal.test.tsx b/src/components/sideBar/components/BlockExplorerModal.test.tsx index 98761a9f..3e2198c8 100644 --- a/src/components/sideBar/components/BlockExplorerModal.test.tsx +++ b/src/components/sideBar/components/BlockExplorerModal.test.tsx @@ -2,8 +2,7 @@ import React from "react"; import { render, screen, fireEvent } from "../../../test-utils"; import BlockExplorerModal from "./BlockExplorerModal"; import { BlockExplorerEnum } from "../../appstate"; - -jest.mock("../../../electronBridge"); +import { ContextApp, defaultAppState } from "../../../context/ContextAppState"; beforeAll(() => { const div = document.createElement("div"); @@ -13,7 +12,9 @@ beforeAll(() => { require("react-modal").setAppElement("#root"); }); -const modalInput = { +// Default block explorer values used by most tests. Individual tests can +// override any subset by spreading and overriding when building `contextValue`. +const defaultBlockExplorerValues = { blockExplorerMainnetTransaction: BlockExplorerEnum.Zcashexplorer, blockExplorerTestnetTransaction: BlockExplorerEnum.Zcashexplorer, blockExplorerMainnetAddress: BlockExplorerEnum.Zcashexplorer, @@ -24,131 +25,114 @@ const modalInput = { blockExplorerTestnetAddressCustom: "", }; -const baseProps = { - modalIsOpen: true, - modalInput, - setModalInput: jest.fn(), - closeModal: jest.fn(), - modalTitle: "Block Explorer Settings", +type RenderOpts = { + modalIsOpen?: boolean; + closeModal?: () => void; + modalTitle?: string; + setBlockExplorer?: jest.Mock; + blockExplorerValues?: Partial; +}; + +const renderModal = (opts: RenderOpts = {}) => { + const { + modalIsOpen = true, + closeModal = jest.fn(), + modalTitle = "Block Explorer Settings", + setBlockExplorer = jest.fn(), + blockExplorerValues = {}, + } = opts; + + const contextValue = { + ...defaultAppState, + ...defaultBlockExplorerValues, + ...blockExplorerValues, + setBlockExplorer, + }; + + return { + setBlockExplorer, + closeModal, + ...render( + + + , + ), + }; }; describe("BlockExplorerModal", () => { it("renders the modal title", () => { - render(); + renderModal(); expect(screen.getByText("Block Explorer Settings")).toBeInTheDocument(); }); it("renders when closed without showing content", () => { - render(); + renderModal({ modalIsOpen: false }); expect(screen.queryByText("Block Explorer Settings")).not.toBeInTheDocument(); }); it("calls closeModal when Cancel is clicked", () => { - const closeModal = jest.fn(); - render(); + const { closeModal } = renderModal(); fireEvent.click(screen.getByRole("button", { name: /^cancel$/i })); expect(closeModal).toHaveBeenCalledTimes(1); }); it("renders both Mainnet and Testnet sections", () => { - render(); + renderModal(); expect(screen.getByText("Mainnet")).toBeInTheDocument(); expect(screen.getByText("Testnet")).toBeInTheDocument(); }); - it("saves explorer choices through ipcRenderer.invoke when Save is clicked", async () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { ipcRenderer } = require("../../../electronBridge"); - (ipcRenderer.invoke as jest.Mock).mockResolvedValue(undefined); - const setModalInput = jest.fn(); - const closeModal = jest.fn(); - render(); + it("calls setBlockExplorer with the form values when Save is clicked", () => { + const { setBlockExplorer, closeModal } = renderModal(); fireEvent.click(screen.getByRole("button", { name: /^save$/i })); - // setModalInput is called synchronously before the async invoke - expect(setModalInput).toHaveBeenCalled(); - // Wait for the async chain - await new Promise((r) => setTimeout(r, 30)); - expect(ipcRenderer.invoke).toHaveBeenCalledWith("saveSettings", expect.any(Object)); + expect(setBlockExplorer).toHaveBeenCalledTimes(1); + expect(setBlockExplorer).toHaveBeenCalledWith(expect.objectContaining(defaultBlockExplorerValues)); expect(closeModal).toHaveBeenCalled(); }); it("disables Save when Custom is selected but URL is empty", () => { - render( - , - ); + renderModal({ + blockExplorerValues: { blockExplorerMainnetTransaction: BlockExplorerEnum.Custom }, + }); expect(screen.getByRole("button", { name: /^save$/i })).toBeDisabled(); }); - it("normalizes custom URLs to end in '/' on save", async () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { ipcRenderer } = require("../../../electronBridge"); - (ipcRenderer.invoke as jest.Mock).mockResolvedValue(undefined); - const setModalInput = jest.fn(); - render( - , - ); + it("normalizes custom URLs to end in '/' on save", () => { + const { setBlockExplorer } = renderModal({ + blockExplorerValues: { + blockExplorerMainnetTransaction: BlockExplorerEnum.Custom, + blockExplorerMainnetTransactionCustom: "https://my.explorer/tx", + }, + }); fireEvent.click(screen.getByRole("button", { name: /^save$/i })); - await new Promise((r) => setTimeout(r, 30)); - const saved = setModalInput.mock.calls[0][0]; + const saved = setBlockExplorer.mock.calls[0][0]; expect(saved.blockExplorerMainnetTransactionCustom).toBe("https://my.explorer/tx/"); }); - it("preserves custom URLs that already end in '='", async () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { ipcRenderer } = require("../../../electronBridge"); - (ipcRenderer.invoke as jest.Mock).mockResolvedValue(undefined); - const setModalInput = jest.fn(); - render( - { + if (!modalIsOpen) return; + setBlockExplorerMainnetTransaction(ctxMainnetTransaction); + setBlockExplorerTestnetTransaction(ctxTestnetTransaction); + setBlockExplorerMainnetAddress(ctxMainnetAddress); + setBlockExplorerTestnetAddress(ctxTestnetAddress); + setBlockExplorerMainnetTransactionCustom(ctxMainnetTransactionCustom); + setBlockExplorerTestnetTransactionCustom(ctxTestnetTransactionCustom); + setBlockExplorerMainnetAddressCustom(ctxMainnetAddressCustom); + setBlockExplorerTestnetAddressCustom(ctxTestnetAddressCustom); + }, [ + modalIsOpen, + ctxMainnetTransaction, + ctxTestnetTransaction, + ctxMainnetAddress, + ctxTestnetAddress, + ctxMainnetTransactionCustom, + ctxTestnetTransactionCustom, + ctxMainnetAddressCustom, + ctxTestnetAddressCustom, + ]); const handleCancel = () => { - setBlockExplorerMainnetAddress(modalInput.blockExplorerMainnetAddress); - setBlockExplorerMainnetAddressCustom(modalInput.blockExplorerMainnetAddressCustom); - setBlockExplorerMainnetTransaction(modalInput.blockExplorerMainnetTransaction); - setBlockExplorerMainnetTransactionCustom(modalInput.blockExplorerMainnetTransactionCustom); - setBlockExplorerTestnetAddress(modalInput.blockExplorerTestnetAddress); - setBlockExplorerTestnetAddressCustom(modalInput.blockExplorerTestnetAddressCustom); - setBlockExplorerTestnetTransaction(modalInput.blockExplorerTestnetTransaction); - setBlockExplorerTestnetTransactionCustom(modalInput.blockExplorerTestnetTransactionCustom); + // Just close — the next time the modal opens, the effect above re-syncs + // the draft from context. closeModal(); }; - const handleSave = async () => { + const handleSave = () => { const toSave = { blockExplorerMainnetAddress, blockExplorerMainnetAddressCustom: normalizeCustom( @@ -85,12 +101,7 @@ const BlockExplorerModal = ({ blockExplorerTestnetTransactionCustom, ), }; - setBlockExplorerMainnetAddressCustom(toSave.blockExplorerMainnetAddressCustom); - setBlockExplorerMainnetTransactionCustom(toSave.blockExplorerMainnetTransactionCustom); - setBlockExplorerTestnetAddressCustom(toSave.blockExplorerTestnetAddressCustom); - setBlockExplorerTestnetTransactionCustom(toSave.blockExplorerTestnetTransactionCustom); - setModalInput(toSave); - await ipcRenderer.invoke("saveSettings", { key: "blockexplorer", value: toSave }); + setBlockExplorer(toSave); closeModal(); }; diff --git a/src/components/sideBar/components/PriceTorModal.tsx b/src/components/sideBar/components/PriceTorModal.tsx new file mode 100644 index 00000000..2fe30bcb --- /dev/null +++ b/src/components/sideBar/components/PriceTorModal.tsx @@ -0,0 +1,113 @@ +import Modal from "react-modal"; +import { useContext, useEffect, useState } from "react"; +import cstyles from "../../common/Common.module.css"; +import { ContextApp } from "../../../context/ContextAppState"; +import torOnion from "../../../assets/img/tor-onion.svg"; + +type PriceTorModalProps = { + modalIsOpen: boolean; + closeModal: () => void; + modalTitle: string; +}; + +const PriceTorModal = ({ modalIsOpen, closeModal, modalTitle }: PriceTorModalProps) => { + const { priceWithTor, setPriceWithTor } = useContext(ContextApp); + + // Local draft state — lets the user toggle radios without committing until + // they press Save. Re-synced with the context value every time the modal + // opens, so reopening doesn't leak the previous session's draft. + const [draft, setDraft] = useState(priceWithTor); + useEffect(() => { + if (modalIsOpen) setDraft(priceWithTor); + }, [modalIsOpen, priceWithTor]); + + const handleCancel = () => { + setDraft(priceWithTor); + closeModal(); + }; + + const handleSave = () => { + setPriceWithTor(draft); + closeModal(); + }; + + return ( + +
+ Tor + {modalTitle} +
+ +
+
+ Choose how to fetch the ZEC price. Tor adds latency but hides the price-fetch request from your network + observer. The conventional path uses a plain HTTPS API. +
+ + + + +
+ +
+ + +
+
+ ); +}; + +export default PriceTorModal; diff --git a/src/components/usdValue/UsdValue.tsx b/src/components/usdValue/UsdValue.tsx new file mode 100644 index 00000000..bb23d5aa --- /dev/null +++ b/src/components/usdValue/UsdValue.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import Utils from "../../utils/utils"; + +type Props = { + /** Current USD price per ZEC. Falsy / 0 renders the unified `USD --` fallback. */ + price?: number; + /** + * If provided, renders the total value `price * amount`. If absent, renders + * the per-ZEC rate (`USD x.xx / ZEC`). Both modes share the same `USD --` + * fallback when the price is unavailable. + */ + amount?: number; + /** Optional className passed through to the wrapper span. */ + className?: string; + /** Optional inline style passed through to the wrapper span. */ + style?: React.CSSProperties; +}; + +/** + * Single place that turns a ZEC price (and optionally an amount) into the + * USD string the UI shows. Two call shapes: + * + * // total value + * // rate per ZEC + * + * Both fall back to `USD --` when `price` is 0/undefined. The total form + * additionally renders `USD < 0.01` for sub-cent values. Centralising the + * fallback here keeps the UI consistent — there are no longer mixed + * variants like `USD -- / ZEC` floating around. + */ +const UsdValue: React.FC = ({ price, amount, className, style }) => { + const text = amount === undefined ? Utils.getZecRateString(price) : Utils.getZecToUsdString(price, amount); + return ( + + {text} + + ); +}; + +export default UsdValue; diff --git a/src/components/usdValue/index.ts b/src/components/usdValue/index.ts new file mode 100644 index 00000000..ffb73838 --- /dev/null +++ b/src/components/usdValue/index.ts @@ -0,0 +1 @@ +export { default as UsdValue } from "./UsdValue"; diff --git a/src/context/ContextAppState.tsx b/src/context/ContextAppState.tsx index 1cf73f4b..afd71e97 100644 --- a/src/context/ContextAppState.tsx +++ b/src/context/ContextAppState.tsx @@ -49,6 +49,10 @@ export const defaultAppState: AppState = { calculateShieldFee: async () => 0, handleShieldButton: () => {}, setAddLabel: () => {}, + zecPrice: 0, + priceWithTor: false, + setPriceWithTor: () => {}, + lastPriceViaTor: false, blockExplorerMainnetTransaction: BlockExplorerEnum.Zcashexplorer, blockExplorerTestnetTransaction: BlockExplorerEnum.Zcashexplorer, blockExplorerMainnetAddress: BlockExplorerEnum.Zcashexplorer, @@ -57,6 +61,7 @@ export const defaultAppState: AppState = { blockExplorerTestnetTransactionCustom: "", blockExplorerMainnetAddressCustom: "", blockExplorerTestnetAddressCustom: "", + setBlockExplorer: () => {}, }; export const ContextApp = React.createContext(defaultAppState); diff --git a/src/index.tsx b/src/index.tsx index 9f18b8b1..75e0c99c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,8 +6,18 @@ import Root from "./root/Root"; import "./components/common/Global.css"; if (process.env.NODE_ENV !== "development") { - console.log = () => {}; - console.debug = () => {}; + // Silence all console output in production. The main process's + // console-message listener writes everything (any level) to startup.log on + // disk, so leaving error/warn/info/trace active would risk a future + // `console.error(\`bad seed ${seedStr}\`)` (or similar) leaking sensitive + // material to a persistent log file. + const noop = () => {}; + console.log = noop; + console.debug = noop; + console.info = noop; + console.warn = noop; + console.error = noop; + console.trace = noop; } const container = document.getElementById("root"); diff --git a/src/native.node.d.ts b/src/native.node.d.ts index 0503dd3f..eae094f4 100644 --- a/src/native.node.d.ts +++ b/src/native.node.d.ts @@ -72,14 +72,14 @@ export function remove_transaction(txid: string): Promise; export function get_spendable_balance_with_address(address: string, zennies: string): Promise; export function get_spendable_balance_total(): Promise; export function set_option_wallet(): Promise; -export function get_option_wallet(): Promise; -export function create_tor_client(data_dir: string): Promise; +// The renderer calls this with no args; the main process derives the Tor data +// directory from userData and passes it to Rust. See electron.js handler. +export function create_tor_client(): Promise; export function remove_tor_client(): Promise; export function get_unified_addresses(): Promise; export function get_transparent_addresses(): Promise; export function create_new_unified_address(receivers: string): Promise; export function create_new_transparent_address(): Promise; -export function check_my_address(): Promise; export function get_wallet_save_required(): Promise; export function set_config_wallet_to_test(): Promise; export function set_config_wallet_to_prod(performance_level: string, min_confirmations: number): Promise; @@ -95,5 +95,3 @@ export function delete_wallet( min_confirmations: number, wallet_name: string, ): Promise; -export function set_wallet_base_dir(path: string): boolean; -export function start_security_scoped_access(bookmark_b64: string): boolean; diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts index 280b16f0..ecd6bd9e 100644 --- a/src/react-app-env.d.ts +++ b/src/react-app-env.d.ts @@ -20,6 +20,11 @@ declare module "*.gif" { export default src; } +declare module "*.svg" { + const src: string; + export default src; +} + declare module "*.jpg" { const src: string; export default src; diff --git a/src/root/Routes.tsx b/src/root/Routes.tsx index 3a5b7a7a..1e0e3087 100644 --- a/src/root/Routes.tsx +++ b/src/root/Routes.tsx @@ -110,7 +110,6 @@ const AppRoutes: React.FC = () => { const setInfo = useCallback((newInfo: InfoClass) => { setInfoState((prev) => { if (deepEqual(prev, newInfo)) return prev; - if (!newInfo.zecPrice) newInfo.zecPrice = prev.zecPrice; return newInfo; }); }, []); @@ -132,15 +131,15 @@ const AppRoutes: React.FC = () => { }, 5000); }, []); - const setZecPrice = useCallback((price?: number) => { - if (!price) return; - setInfoState((prev) => { - if (price === prev.zecPrice) return prev; - const newInfo = new InfoClass(); - Object.assign(newInfo, prev); - newInfo.zecPrice = price; - return newInfo; - }); + // ZEC price + reality flag of the last successful fetch. Both live at the + // top level (NOT inside InfoClass) because the periodic info-refresh + // rebuilds InfoClass and would otherwise clobber them every 5s cycle. + // Updated together by RPC.getZecPrice on each fetch via this single setter. + const [zecPrice, setZecPriceState] = useState(0); + const [lastPriceViaTor, setLastPriceViaTorState] = useState(false); + const setZecPrice = useCallback((price?: number, viaTor?: boolean) => { + if (typeof price === "number") setZecPriceState(price); + if (typeof viaTor === "boolean") setLastPriceViaTorState(viaTor); }, []); const setReadOnly = useCallback((val: boolean) => setReadOnlyState(val), []); @@ -163,14 +162,33 @@ const AppRoutes: React.FC = () => { const setSendPageState = useCallback((val: SendPageStateClass) => setSendPageStateState(val), []); - const setBlockExplorer = useCallback((blockExplorer: any) => { - // blockExplorer object is spread into individual state fields via addLabelState workaround — - // simpler to keep them in one object; here we use a spread onto named setters. - // Since these are simple values, no deepEqual needed. - setAddLabelStateState((prev) => prev); // trigger re-read; actual set below - // Store block explorer fields inside a dedicated state slice would be cleaner, - // but to match the original class field-by-field setState we set each individually. + // Block explorer config. Source of truth is electron-settings (`blockexplorer` + // key); the React state mirrors it for context consumers. The setter writes + // both atomically. Loaded at boot inside the existing `loadSettings` effect + // below, so consumers (Sidebar, BlockExplorerModal) read it directly from + // context with no prop drilling. + const setBlockExplorer = useCallback(async (blockExplorer: any) => { setBlockExplorerState(blockExplorer); + try { + await ipcRenderer.invoke("saveSettings", { key: "blockexplorer", value: blockExplorer }); + } catch (e) { + console.warn("setBlockExplorer: could not persist setting", e); + } + }, []); + + // ZEC price fetch via Tor. The persisted source of truth is electron-settings + // (`pricewithtor`); this state mirrors it for React consumers (Dashboard, + // PriceTorModal). The setter writes to both and triggers an immediate + // price refresh so the dashboard indicator updates without a timer wait. + const [priceWithTorState, setPriceWithTorReact] = useState(false); + const setPriceWithTor = useCallback(async (v: boolean) => { + setPriceWithTorReact(v); + try { + await ipcRenderer.invoke("saveSettings", { key: "pricewithtor", value: v }); + } catch (e) { + console.warn("setPriceWithTor: could not persist setting", e); + } + rpcRef.current?.getZecPrice(); }, []); // Block explorer fields kept in a single object to avoid 8 useState @@ -217,6 +235,10 @@ const AppRoutes: React.FC = () => { const isLocked = !!(allSettings?.requireDeviceAuth && authAvailability === "available"); setLocked(isLocked); setLockChecked(true); + setPriceWithTorReact(!!allSettings?.pricewithtor); + if (allSettings && Object.prototype.hasOwnProperty.call(allSettings, "blockexplorer")) { + setBlockExplorerState(allSettings.blockexplorer); + } })(); const appsecurityListener = () => setSecurityModalOpen(true); @@ -467,6 +489,10 @@ const AppRoutes: React.FC = () => { calculateShieldFee, handleShieldButton, setAddLabel, + zecPrice, + priceWithTor: priceWithTorState, + setPriceWithTor, + lastPriceViaTor, blockExplorerMainnetAddress: blockExplorerConfig.blockExplorerMainnetAddress, blockExplorerMainnetAddressCustom: blockExplorerConfig.blockExplorerMainnetAddressCustom, blockExplorerMainnetTransaction: blockExplorerConfig.blockExplorerMainnetTransaction, @@ -475,6 +501,7 @@ const AppRoutes: React.FC = () => { blockExplorerTestnetAddressCustom: blockExplorerConfig.blockExplorerTestnetAddressCustom, blockExplorerTestnetTransaction: blockExplorerConfig.blockExplorerTestnetTransaction, blockExplorerTestnetTransactionCustom: blockExplorerConfig.blockExplorerTestnetTransactionCustom, + setBlockExplorer, }), [ totalBalance, @@ -508,7 +535,12 @@ const AppRoutes: React.FC = () => { calculateShieldFee, handleShieldButton, setAddLabel, + zecPrice, + priceWithTorState, + setPriceWithTor, + lastPriceViaTor, blockExplorerConfig, + setBlockExplorer, ], ); @@ -543,7 +575,6 @@ const AppRoutes: React.FC = () => {
)} @@ -596,7 +627,6 @@ const AppRoutes: React.FC = () => { setCurrentWallet={setCurrentWallet} setCurrentWalletOpenError={setCurrentWalletOpenError} setFetchError={setFetchError} - setBlockExplorer={setBlockExplorer} /> } /> diff --git a/src/rpc/rpc.ts b/src/rpc/rpc.ts index 3d6767c0..f5508523 100644 --- a/src/rpc/rpc.ts +++ b/src/rpc/rpc.ts @@ -13,7 +13,7 @@ import { ServerChainNameEnum, } from "../components/appstate"; -import { native } from "../electronBridge"; +import { native, ipcRenderer } from "../electronBridge"; import { RPCInfoType } from "./components/RPCInfoType"; import { RPCValueTransferType } from "./components/RPCValueTransferType"; @@ -24,7 +24,7 @@ export default class RPC { fnSetValueTransfersList: (t: ValueTransferClass[]) => void; fnSetMessagesList: (t: ValueTransferClass[]) => void; fnSetInfo: (info: InfoClass) => void; - fnSetZecPrice: (p?: number) => void; + fnSetZecPrice: (p?: number, viaTor?: boolean) => void; fnSetSyncStatus: (ss: SyncStatusType) => void; fnSetVerificationProgress: (verificationProgress: number | null) => void; fnSetFetchError: (command: string, error: string) => void; @@ -46,7 +46,7 @@ export default class RPC { fnSetValueTransfersList: (t: ValueTransferClass[]) => void, fnSetMessagesList: (t: ValueTransferClass[]) => void, fnSetInfo: (info: InfoClass) => void, - fnSetZecPrice: (p?: number) => void, + fnSetZecPrice: (p?: number, viaTor?: boolean) => void, fnSetSyncStatus: (ss: SyncStatusType) => void, fnSetVerificationProgress: (verificationProgress: number | null) => void, fnSetFetchError: (command: string, error: string) => void, @@ -79,6 +79,7 @@ export default class RPC { this.fetchInfo(), this.fetchAddresses(), this.fetchTotalBalance(), + this.getZecPrice(), RPC.doSave(), this.fetchTandZandOValueTransfers(), this.fetchTandZandOMessages(), @@ -91,6 +92,7 @@ export default class RPC { await this.fetchAddresses(); await this.fetchTotalBalance(); await this.fetchInfo(); + void this.getZecPrice(); await this.fetchTandZandOMessages(); // every 5 seconds the App update part of the data @@ -255,20 +257,10 @@ export default class RPC { info.currencyName = info.chainName === ServerChainNameEnum.mainChainName ? "ZEC" : "TAZ"; info.solps = 0; - // Also set `zecPrice` manually - const resultStr: string = await native.zec_price("false"); - if (resultStr) { - if (resultStr.toLowerCase().startsWith("error")) { - console.error(`Error fetching price Info ${resultStr}`); - info.zecPrice = 0; - } else { - const resultJSON = JSON.parse(resultStr); - info.zecPrice = resultJSON.current_price; - } - } else { - console.error(`Error fetching price Info ${resultStr}`); - info.zecPrice = 0; - } + // ZEC price is no longer fetched here — it lives outside InfoClass + // (see `getZecPrice` below, scheduled by `runTaskPromises`). Folding + // the price fetch into the info refresh meant that every 5s cycle + // overwrote the Tor-fetched value with a hard-coded `false` HTTP call. // zingolib version let zingolibStr: string = await native.get_version(); @@ -784,23 +776,78 @@ export default class RPC { } async getZecPrice() { + // Read the user's "fetch price via Tor" preference from persisted + // settings. Default off — if the key is missing or anything throws while + // loading settings we keep the conventional HTTP path. The Tor client is + // created lazily here only when Tor is requested. + let withTor = false; + let triedTor = false; try { - const resultStr: string = await native.zec_price("false"); + const settings = await ipcRenderer.invoke("loadSettings"); + withTor = !!settings?.pricewithtor; + } catch (e) { + console.warn(`getZecPrice: could not load settings, falling back to HTTP. ${e}`); + } + + if (withTor) { + triedTor = true; + try { + const createRes: string = await native.create_tor_client(); + // Always dump what the native side actually returned so we can tell + // a "client already exists" no-op from a real failure when the user + // reports Tor not working. + console.log("[Tor] create_tor_client returned:", JSON.stringify(createRes)); + if (createRes && createRes.toLowerCase().startsWith("error")) { + if (!createRes.toLowerCase().includes("already")) { + console.error(`[Tor] create failed, falling back to HTTP: ${createRes}`); + withTor = false; + } else { + console.log("[Tor] client already exists — reusing"); + } + } + } catch (e) { + console.error(`[Tor] create_tor_client threw, falling back to HTTP:`, e); + withTor = false; + } + } + + try { + const resultStr: string = await native.zec_price(withTor ? "true" : "false"); + console.log(`[Tor] zec_price(${withTor ? "true" : "false"}) returned:`, JSON.stringify(resultStr)); if (resultStr) { if (resultStr.toLowerCase().startsWith("error")) { - console.error(`Error fetching price ${resultStr}`); - this.fnSetZecPrice(0); + console.error(`[Tor] zec_price error: ${resultStr}`); + // If Tor was attempted and the native side complained, retry over + // plain HTTP so the user still sees a price. The dashboard + // indicator will reflect that we did NOT use Tor for this value. + if (withTor) { + console.warn("[Tor] retrying without Tor after Tor-path failure"); + try { + const fallback: string = await native.zec_price("false"); + console.log("[Tor] HTTP fallback returned:", JSON.stringify(fallback)); + if (fallback && !fallback.toLowerCase().startsWith("error")) { + const json = JSON.parse(fallback); + this.fnSetZecPrice(json.current_price, false); + return; + } + } catch (e) { + console.error(`[Tor] HTTP fallback also failed:`, e); + } + } + this.fnSetZecPrice(0, false); } else { const resultJSON = JSON.parse(resultStr); - this.fnSetZecPrice(resultJSON.current_price); + // Reality, not intent: only mark as via-Tor if we actually went + // through Tor for this fetch. + this.fnSetZecPrice(resultJSON.current_price, triedTor && withTor); } } else { - console.error(`Error fetching price ${resultStr}`); - this.fnSetZecPrice(0); + console.error(`[Tor] zec_price returned empty result`); + this.fnSetZecPrice(0, false); } } catch (error) { - console.error(`Critical Error get price ${error}`); + console.error(`[Tor] zec_price threw:`, error); } } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 9f0989d5..5580d089 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -277,6 +277,17 @@ export default class Utils { return `USD ${(price * zecValue).toFixed(2)}`; } + /** + * Formats the per-ZEC exchange rate. Returns `USD --` when the price is + * missing — matches the fallback of `getZecToUsdString` so the UI is + * consistent everywhere a USD figure may not be available. Otherwise + * `USD x.xx / ZEC`. + */ + static getZecRateString(price?: number): string { + if (!price) return "USD --"; + return `USD ${price.toFixed(2)} / ZEC`; + } + static utf16Split(s: string, chunksize: number): string[] { const ans: string[] = []; diff --git a/src/version.ts b/src/version.ts index ed42f1e8..3ce16903 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,3 +1,3 @@ -const APP_VERSION = "2.0.16 (153)"; +const APP_VERSION = "2.0.17 (154)"; export default APP_VERSION; diff --git a/yarn.lock b/yarn.lock index fc391a84..f1a8c252 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4234,9 +4234,9 @@ brace-expansion@^2.0.1, brace-expansion@^2.0.2: balanced-match "^1.0.0" brace-expansion@^5.0.5: - version "5.0.5" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" - integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== + version "5.0.6" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.6.tgz#ec68fe0a641a29d8711579caf641d05bae1f2285" + integrity sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g== dependencies: balanced-match "^4.0.2" @@ -12137,10 +12137,10 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== -uuid@^11.1.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" - integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A== +uuid@^11.1.1: + version "11.1.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.1.tgz#f6d81d2e1c65d00762e5e29b16c5d2d995e208ad" + integrity sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ== uuid@^8.3.2: version "8.3.2"