From da71cfb0e490379199146d8fdab21863a3cf006a Mon Sep 17 00:00:00 2001 From: blocknodes Date: Fri, 18 Sep 2026 03:15:33 +0000 Subject: [PATCH 1/4] feat(wallet): add a regtest network for local devnets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds WalletNetwork::Regtest(RegtestUpgrade) so the wallet can run against a local regtest devnet such as thus-spoke-zakura. Regtest has no fixed activation table, so the network carries the newest upgrade the node has active (NU6..NU6.3, all at height 1). A new WalletParams enum (public Network | LocalNetwork) implements Parameters and is the one type the wallet db, sync driver, and tx builder use, so the branch id and Orchard circuit are derived correctly for the devnet. - uregtest1… addresses via NetworkType::Regtest - separate "regtest" wallet data dir - regtest wallets scan from block 2 (the whole devnet chain), since groups are usually faucet-funded before their wallet is opened - network-mismatch hints for regtest addresses in both directions - enable zcash_protocol's local-consensus feature; declare the zcash_unstable cfg the LocalNetwork literal follows Co-Authored-By: Claude Opus 5 --- src-tauri/core/Cargo.toml | 9 +- src-tauri/core/src/wallet.rs | 262 +++++++++++++++++++++++++++++++---- 2 files changed, 243 insertions(+), 28 deletions(-) diff --git a/src-tauri/core/Cargo.toml b/src-tauri/core/Cargo.toml index 6a979a2..6e3dcf0 100644 --- a/src-tauri/core/Cargo.toml +++ b/src-tauri/core/Cargo.toml @@ -63,7 +63,9 @@ zcash_client_backend = { version = "=0.24.0-rc.7", optional = true, default-feat zcash_client_sqlite = { version = "=0.22.0-rc.8", optional = true, features = ["orchard", "transparent-inputs", "unstable", "serde"] } pczt = { version = "0.9.1", optional = true, features = ["signer"] } zcash_keys = { version = "0.16.1", optional = true, features = ["orchard"] } -zcash_protocol = { version = "0.10.3", optional = true } +# `local-consensus` exposes `LocalNetwork`, the regtest parameters used for a +# local devnet (see `WalletNetwork::Regtest`). +zcash_protocol = { version = "0.10.3", optional = true, features = ["local-consensus"] } tonic = { version = "0.14", optional = true, default-features = false, features = ["transport", "tls-webpki-roots"] } # SQLCipher (bundled amalgamation) so the per-group wallet database is encrypted # at rest. Feature-unifies with zcash_client_sqlite's `bundled` rusqlite: the @@ -95,3 +97,8 @@ wallet = [ [dev-dependencies] tempfile = "3" + +[lints.rust] +# `zcash_unstable = "nu7"` is the zcash crates' opt-in for the next upgrade; the +# regtest `LocalNetwork` literal in wallet.rs must follow it to stay buildable. +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(zcash_unstable, values("nu7", "nutachyon"))'] } diff --git a/src-tauri/core/src/wallet.rs b/src-tauri/core/src/wallet.rs index 2995a85..0b31f20 100644 --- a/src-tauri/core/src/wallet.rs +++ b/src-tauri/core/src/wallet.rs @@ -13,7 +13,8 @@ use tonic::transport::{Channel, ClientTlsConfig}; use zcash_client_backend::proto::service::{ compact_tx_streamer_client::CompactTxStreamerClient, Empty, }; -use zcash_protocol::consensus::Network; +use zcash_protocol::consensus::{BlockHeight, Network, NetworkType, NetworkUpgrade, Parameters}; +use zcash_protocol::local_consensus::LocalNetwork; use crate::error::CoreError; @@ -23,49 +24,176 @@ use crate::error::CoreError; pub enum WalletNetwork { Test, Main, + /// A local regtest devnet (e.g. thus-spoke-zakura). Regtest chains choose + /// their own upgrade heights, so the network carries the latest upgrade the + /// node has active; see [`RegtestUpgrade`]. + Regtest(RegtestUpgrade), +} + +/// The newest network upgrade active on a local regtest devnet, from genesis. +/// +/// Regtest has no fixed activation table: each node picks its own `nuparams`. +/// Devnets activate everything up to some upgrade at height 1, so "which +/// upgrade is the newest" is enough to rebuild the node's consensus parameters. +/// The wallet must agree with the node on this: it picks the consensus branch +/// id and the Orchard circuit a transaction is built with. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RegtestUpgrade { + /// NU6 — what thus-spoke-zakura ships by default. + #[default] + Nu6, + Nu6_1, + Nu6_2, + /// NU6.3 — Ironwood. + Nu6_3, +} + +impl RegtestUpgrade { + pub const ALL: [RegtestUpgrade; 4] = [ + RegtestUpgrade::Nu6, + RegtestUpgrade::Nu6_1, + RegtestUpgrade::Nu6_2, + RegtestUpgrade::Nu6_3, + ]; + + /// The settings/IPC spelling (`nu6`, `nu6_1`, …). + pub fn as_str(self) -> &'static str { + match self { + RegtestUpgrade::Nu6 => "nu6", + RegtestUpgrade::Nu6_1 => "nu6_1", + RegtestUpgrade::Nu6_2 => "nu6_2", + RegtestUpgrade::Nu6_3 => "nu6_3", + } + } + + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|u| u.as_str() == s) + } + + /// The upgrade whose consensus branch id a node reports (lowercase hex, as + /// in [`LightwalletdInfo::consensus_branch_id`]). `None` for a branch older + /// than NU6 or one this build doesn't know. + pub fn from_branch_id(branch_hex: &str) -> Option { + match branch_hex.trim().to_ascii_lowercase().as_str() { + "c8e71055" => Some(RegtestUpgrade::Nu6), + "4dec4df0" => Some(RegtestUpgrade::Nu6_1), + "5437f330" => Some(RegtestUpgrade::Nu6_2), + NU6_3_BRANCH_ID => Some(RegtestUpgrade::Nu6_3), + _ => None, + } + } + + /// Regtest consensus parameters: every upgrade up to and including this one + /// active at height 1, everything later inactive. + pub fn local_network(self) -> LocalNetwork { + let one = Some(BlockHeight::from_u32(1)); + let upto = |u: RegtestUpgrade| if self >= u { one } else { None }; + LocalNetwork { + overwinter: one, + sapling: one, + blossom: one, + heartwood: one, + canopy: one, + nu5: one, + nu6: one, + nu6_1: upto(RegtestUpgrade::Nu6_1), + nu6_2: upto(RegtestUpgrade::Nu6_2), + nu6_3: upto(RegtestUpgrade::Nu6_3), + #[cfg(zcash_unstable = "nu7")] + nu7: None, + } + } +} + +/// Consensus parameters for any [`WalletNetwork`]: the fixed public networks or +/// a local regtest chain. The single `Parameters` type the wallet db, sync, and +/// tx builder are instantiated with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WalletParams { + Consensus(Network), + Local(LocalNetwork), +} + +impl Parameters for WalletParams { + fn network_type(&self) -> NetworkType { + match self { + WalletParams::Consensus(n) => n.network_type(), + WalletParams::Local(n) => n.network_type(), + } + } + + fn activation_height(&self, nu: NetworkUpgrade) -> Option { + match self { + WalletParams::Consensus(n) => n.activation_height(nu), + WalletParams::Local(n) => n.activation_height(nu), + } + } } impl WalletNetwork { + /// Parse the settings spelling. A regtest network parsed this way assumes + /// the default upgrade level; callers that know the node's level construct + /// `WalletNetwork::Regtest(level)` directly. pub fn from_str(s: &str) -> Self { match s { "main" => WalletNetwork::Main, + "regtest" => WalletNetwork::Regtest(RegtestUpgrade::default()), _ => WalletNetwork::Test, } } + /// The settings/IPC spelling: `main`, `test`, or `regtest`. + pub fn as_str(self) -> &'static str { + match self { + WalletNetwork::Main => "main", + WalletNetwork::Test => "test", + WalletNetwork::Regtest(_) => "regtest", + } + } + /// The consensus parameters for this network (used by sync/address logic). - pub fn params(self) -> Network { + pub fn params(self) -> WalletParams { match self { - WalletNetwork::Test => Network::TestNetwork, - WalletNetwork::Main => Network::MainNetwork, + WalletNetwork::Test => WalletParams::Consensus(Network::TestNetwork), + WalletNetwork::Main => WalletParams::Consensus(Network::MainNetwork), + WalletNetwork::Regtest(level) => WalletParams::Local(level.local_network()), } } /// The address/key encoding network type. - pub fn network_type(self) -> zcash_protocol::consensus::NetworkType { + pub fn network_type(self) -> NetworkType { match self { - WalletNetwork::Test => zcash_protocol::consensus::NetworkType::Test, - WalletNetwork::Main => zcash_protocol::consensus::NetworkType::Main, + WalletNetwork::Test => NetworkType::Test, + WalletNetwork::Main => NetworkType::Main, + WalletNetwork::Regtest(_) => NetworkType::Regtest, } } - /// A sensible default public lightwalletd endpoint for this network. + /// A sensible default lightwalletd endpoint for this network. For regtest + /// this is lightwalletd's conventional local port; a devnet that picks its + /// own port (thus-spoke-zakura) is found with devnet detection instead. pub fn default_lightwalletd(self) -> &'static str { match self { WalletNetwork::Test => "https://testnet.zec.rocks:443", WalletNetwork::Main => "https://zec.rocks:443", + WalletNetwork::Regtest(_) => "http://127.0.0.1:9067", } } - /// On-disk directory name for this network's wallet data. Testnet and - /// mainnet keep entirely separate databases, blocks caches, and pending + /// On-disk directory name for this network's wallet data. Each network + /// keeps entirely separate databases, blocks caches, and pending /// transactions, so switching networks never shows one network's balance - /// while pointed at the other's chain (and testnet data can't corrupt a - /// mainnet db, or vice versa). + /// while pointed at the other's chain (and testnet or devnet data can't + /// corrupt a mainnet db, or vice versa). + /// + /// All regtest upgrade levels share one directory: the level describes the + /// node's rules, not a different chain. pub fn dir_name(self) -> &'static str { match self { WalletNetwork::Test => "testnet", WalletNetwork::Main => "mainnet", + WalletNetwork::Regtest(_) => "regtest", } } } @@ -265,10 +393,9 @@ use zcash_client_sqlite::util::SystemClock; use zcash_client_sqlite::wallet::init::init_wallet_db; use zcash_client_sqlite::WalletDb; use zcash_keys::keys::UnifiedFullViewingKey; -use zcash_protocol::consensus::BlockHeight; use zcash_protocol::memo::{Memo, MemoBytes}; -type GroupDb = WalletDb; +type GroupDb = WalletDb; /// `(wallet.sqlite path, fsblockdb dir)` for a group on a given network. /// Scoped by network (`...///...`) so testnet and mainnet @@ -722,10 +849,23 @@ pub const DEFAULT_TESTNET_BIRTHDAY: u64 = 3_800_000; /// recover funds that predate a rebuilt wallet, pass an explicit /// `birthday_height` (the command layer supplies the persisted one); for a /// from-scratch testnet rescan, pass [`DEFAULT_TESTNET_BIRTHDAY`]. -pub fn default_birthday_height(_network: WalletNetwork) -> Option { - None +/// +/// A local regtest devnet is the exception: its whole chain is a few hundred +/// blocks, and a developer typically funds the group from the devnet faucet +/// *before* opening its wallet. Scanning from [`REGTEST_BIRTHDAY`] costs +/// nothing and finds those funds. +pub fn default_birthday_height(network: WalletNetwork) -> Option { + match network { + WalletNetwork::Regtest(_) => Some(REGTEST_BIRTHDAY), + WalletNetwork::Test | WalletNetwork::Main => None, + } } +/// First block a regtest wallet scans. lightwalletd cannot serve a devnet's +/// block 1 (thus-spoke-zakura documents scanning from block 2), and block 1 +/// only carries the upgrade-activation coinbase, so nothing is lost. +pub const REGTEST_BIRTHDAY: u64 = 2; + /// Pick the first block to scan: the requested birthday held inside /// `[nu5, tip]`, or the tip when nothing was requested. /// @@ -1027,7 +1167,7 @@ fn split_scan_range(range: ScanRange, batch_size: u32) -> Vec { /// today does not perform it either. Omitting it here keeps the two byte-identical. async fn run_pipelined( client: &mut CompactTxStreamerClient, - params: &Network, + params: &WalletParams, db: &mut GroupDb, batch_size: u32, ) -> Result<(), CoreError> { @@ -1048,7 +1188,7 @@ async fn run_pipelined( /// range) and the caller should restart from a fresh `suggest_scan_ranges`. async fn running_pipelined( client: &mut CompactTxStreamerClient, - params: &Network, + params: &WalletParams, db: &mut GroupDb, batch_size: u32, ) -> Result { @@ -1194,7 +1334,7 @@ async fn running_pipelined( /// signal a restart if scanning surfaced a higher-priority range. The in-memory /// source needs no cache truncation on rewind (each batch is downloaded fresh). fn scan_batch( - params: &Network, + params: &WalletParams, src: &MemBlockSource, db: &mut GroupDb, chain_state: &ChainState, @@ -1500,12 +1640,18 @@ pub async fn prepare_send( let to = Address::decode(¶ms, r).ok_or_else(|| { // Give a network-mismatch hint when the address prefix clearly belongs // to the other network — saves a confusing round-trip for the user. + let looks_regtest = r.starts_with("uregtest") || r.starts_with("zregtestsapling"); + let looks_test = r.starts_with("utest") || r.starts_with("ztestsapling"); + let looks_main = (r.starts_with("u1") || r.starts_with("zs1") || r.starts_with("t1")) + && !looks_test + && !looks_regtest; let hint = match network { - WalletNetwork::Main if r.starts_with("utest") || r.starts_with("ztestsapling") => - " — this looks like a testnet address but you are on mainnet", - WalletNetwork::Test if (r.starts_with("u1") || r.starts_with("zs1") || r.starts_with("t1")) - && !r.starts_with("utest") => - " — this looks like a mainnet address but you are on testnet", + WalletNetwork::Main if looks_test => " — this looks like a testnet address but you are on mainnet", + WalletNetwork::Main if looks_regtest => " — this looks like a devnet (regtest) address but you are on mainnet", + WalletNetwork::Test if looks_main => " — this looks like a mainnet address but you are on testnet", + WalletNetwork::Test if looks_regtest => " — this looks like a devnet (regtest) address but you are on testnet", + WalletNetwork::Regtest(_) if looks_main => " — this looks like a mainnet address but you are on the local devnet", + WalletNetwork::Regtest(_) if looks_test => " — this looks like a testnet address but you are on the local devnet", _ => "", }; CoreError::Crypto(format!("invalid recipient address{hint}")) @@ -2374,10 +2520,55 @@ mod tests { #[test] fn network_params_and_defaults() { - assert_eq!(WalletNetwork::Test.params(), Network::TestNetwork); - assert_eq!(WalletNetwork::Main.params(), Network::MainNetwork); + assert_eq!(WalletNetwork::Test.params(), WalletParams::Consensus(Network::TestNetwork)); + assert_eq!(WalletNetwork::Main.params(), WalletParams::Consensus(Network::MainNetwork)); assert!(WalletNetwork::Test.default_lightwalletd().starts_with("https://")); assert!(WalletNetwork::Main.default_lightwalletd().starts_with("https://")); + + // Regtest: local params, regtest encoding, its own data dir, and a + // loopback plaintext default that the endpoint guard accepts. + let dev = WalletNetwork::Regtest(RegtestUpgrade::Nu6); + assert!(matches!(dev.params(), WalletParams::Local(_))); + assert_eq!(dev.network_type(), NetworkType::Regtest); + assert_eq!(dev.dir_name(), "regtest"); + assert!(validate_endpoint_security(dev.default_lightwalletd()).is_ok()); + assert_eq!(WalletNetwork::from_str("regtest"), dev); + assert_eq!(WalletNetwork::from_str(dev.as_str()), dev); + } + + #[test] + fn regtest_upgrade_from_branch_id() { + // The branch ids a node reports for each upgrade we can build for. + assert_eq!(RegtestUpgrade::from_branch_id("c8e71055"), Some(RegtestUpgrade::Nu6)); + assert_eq!(RegtestUpgrade::from_branch_id("4dec4df0"), Some(RegtestUpgrade::Nu6_1)); + assert_eq!(RegtestUpgrade::from_branch_id("5437F330"), Some(RegtestUpgrade::Nu6_2)); + assert_eq!(RegtestUpgrade::from_branch_id(NU6_3_BRANCH_ID), Some(RegtestUpgrade::Nu6_3)); + // NU5 (and anything unknown) is not a level we model. + assert_eq!(RegtestUpgrade::from_branch_id("c2d6d0b4"), None); + assert_eq!(RegtestUpgrade::from_branch_id(""), None); + for u in RegtestUpgrade::ALL { + assert_eq!(RegtestUpgrade::parse(u.as_str()), Some(u)); + } + } + + #[test] + fn regtest_params_activate_only_up_to_level() { + // Each level must make the wallet agree with the node on the branch id + // at the tip: that is what picks the tx version and Orchard circuit. + for (level, branch) in [ + (RegtestUpgrade::Nu6, "c8e71055"), + (RegtestUpgrade::Nu6_1, "4dec4df0"), + (RegtestUpgrade::Nu6_2, "5437f330"), + (RegtestUpgrade::Nu6_3, NU6_3_BRANCH_ID), + ] { + let net = WalletNetwork::Regtest(level); + assert_eq!(branch_id_for_height(net, 500), branch, "{level:?}"); + assert_eq!(RegtestUpgrade::from_branch_id(branch), Some(level)); + } + let nu6 = RegtestUpgrade::Nu6.local_network(); + assert_eq!(nu6.activation_height(NetworkUpgrade::Nu5), Some(BlockHeight::from_u32(1))); + assert_eq!(nu6.activation_height(NetworkUpgrade::Nu6_1), None); + assert_eq!(nu6.activation_height(NetworkUpgrade::Nu6_3), None); } #[test] @@ -2481,7 +2672,11 @@ mod tests { let sk = Option::::from(SpendingKey::from_bytes([9u8; 32])).unwrap(); let ak: [u8; 32] = FullViewingKey::from(&sk).to_bytes()[..32].try_into().unwrap(); - for net in [WalletNetwork::Test, WalletNetwork::Main] { + for net in [ + WalletNetwork::Test, + WalletNetwork::Main, + WalletNetwork::Regtest(RegtestUpgrade::Nu6), + ] { let keys = crate::zcash::derive_orchard_keys(&ak, net.network_type()).unwrap(); let addr = ufvk_default_address(net, &keys.ufvk).unwrap(); assert_eq!(addr, keys.address, "zcash_keys must agree on {net:?}"); @@ -2534,6 +2729,19 @@ mod tests { assert_eq!(resolve_scan_from(None, TEST_NU5, 4_200_000), 4_200_000); } + #[test] + fn regtest_wallets_scan_the_whole_devnet_chain() { + // A devnet group is usually funded from the faucet before its wallet is + // opened, so regtest scans from block 2 rather than the tip. + for level in RegtestUpgrade::ALL { + let net = WalletNetwork::Regtest(level); + assert_eq!(default_birthday_height(net), Some(REGTEST_BIRTHDAY)); + assert_eq!(resolve_scan_from(default_birthday_height(net), 1, 350), 2); + } + // A brand-new devnet whose tip is below the birthday still anchors. + assert_eq!(resolve_scan_from(Some(REGTEST_BIRTHDAY), 1, 1), 1); + } + #[test] fn explicit_deep_rescan_floor_is_honoured_when_requested() { // Passing DEFAULT_TESTNET_BIRTHDAY explicitly (an opt-in deep rescan) From 8cac7ed6ed4f7b0b6e38db077fe7bae70df47ee1 Mon Sep 17 00:00:00 2001 From: blocknodes Date: Fri, 18 Sep 2026 03:21:09 +0000 Subject: [PATCH 2/4] feat(wallet): devnet config, upgrade detection, and thus-spoke-zakura discovery Command layer for the regtest network: - configured_network(): the one place every command resolves the network, including the devnet upgrade level (override, else detected from the node, else NU6). groups.rs now uses it too, fixing its "test" default that disagreed with the wallet's "main". - set_wallet_config accepts "regtest" (it previously coerced anything non-main to "test") and an optional upgrade-level override. - A connection test against a regtest node records the upgrade level read from its consensus branch id, so "auto" matches the node. - detect_local_devnet: runs `thus-spoke-zakura endpoints --json` (PATH, $TSZ_INSTALL_DIR, ~/.local/bin), requires a loopback lightwalletd, and probes it, since the CLI reports endpoints from a file that outlives the devnet. Linux/macOS only, like the launcher. - ZNS resolution returns a clear error on the devnet instead of "not registered"; signing requests carry "regtest" to co-signers. - Devnet wallets neither reuse nor overwrite a group's recorded birthday (keyed by group only, so a testnet height would otherwise hide every devnet note). Co-Authored-By: Claude Opus 5 --- src-tauri/core/src/wallet.rs | 2 +- src-tauri/src/commands/groups.rs | 7 +- src-tauri/src/commands/wallet.rs | 214 +++++++++++++++++++++++++++---- src-tauri/src/devnet.rs | 167 ++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/state.rs | 11 +- 6 files changed, 371 insertions(+), 32 deletions(-) create mode 100644 src-tauri/src/devnet.rs diff --git a/src-tauri/core/src/wallet.rs b/src-tauri/core/src/wallet.rs index 0b31f20..eb28a09 100644 --- a/src-tauri/core/src/wallet.rs +++ b/src-tauri/core/src/wallet.rs @@ -277,7 +277,7 @@ fn normalize_endpoint(url: &str) -> String { /// True when the host component of a normalized URL is a loopback address — /// plaintext gRPC is only tolerated against a local node (regtest/dev), never /// against a remote lightwalletd where the traffic would cross the network. -fn is_loopback_host(normalized_url: &str) -> bool { +pub fn is_loopback_host(normalized_url: &str) -> bool { let after_scheme = normalized_url .split_once("://") .map(|(_, rest)| rest) diff --git a/src-tauri/src/commands/groups.rs b/src-tauri/src/commands/groups.rs index 63ed078..84a5ea7 100644 --- a/src-tauri/src/commands/groups.rs +++ b/src-tauri/src/commands/groups.rs @@ -38,10 +38,9 @@ pub async fn group_orchard_keys( if !ciphersuite.contains("Pallas") { return Ok(None); } - // Encode for the configured network (testnet during testing). - let network = frost_app_core::wallet::WalletNetwork::from_str( - state.load_settings().wallet_network.as_deref().unwrap_or("test"), - ); + // Encode for the configured network, resolved the same way as every wallet + // command (so the group page and the wallet never disagree on the prefix). + let network = crate::commands::wallet::configured_network(&state.load_settings()); Ok(Some(frost_app_core::zcash::derive_orchard_keys_hex( &id, network.network_type(), diff --git a/src-tauri/src/commands/wallet.rs b/src-tauri/src/commands/wallet.rs index 2275f93..0e86d5e 100644 --- a/src-tauri/src/commands/wallet.rs +++ b/src-tauri/src/commands/wallet.rs @@ -4,7 +4,7 @@ use frost_app_core::ciphersuite::Suite; use frost_app_core::signing::{run_coordinator, CoordinatorParams}; -use frost_app_core::wallet::{self, LightwalletdInfo, WalletNetwork, WalletStatus}; +use frost_app_core::wallet::{self, LightwalletdInfo, RegtestUpgrade, WalletNetwork, WalletStatus}; use frost_client::api::PublicKey; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, Manager, State}; @@ -13,20 +13,37 @@ use tokio_util::sync::CancellationToken; use uuid::Uuid; use crate::error::{AppError, AppResult}; -use crate::state::{AppState, CeremonyHandle}; - -fn network_from_str(s: &str) -> WalletNetwork { - match s { - "main" => WalletNetwork::Main, - _ => WalletNetwork::Test, +use crate::state::{AppState, CeremonyHandle, Settings}; + +/// The wallet network the app is configured for. For a local devnet this +/// includes its upgrade level: the user's override if set, otherwise the level +/// last detected from the node, otherwise NU6 (thus-spoke-zakura's default). +/// Every command resolves the network through here so they all agree. +pub(crate) fn configured_network(settings: &Settings) -> WalletNetwork { + match settings.wallet_network.as_deref().unwrap_or("main") { + "regtest" => WalletNetwork::Regtest( + settings + .regtest_upgrade + .as_deref() + .or(settings.regtest_detected.as_deref()) + .and_then(RegtestUpgrade::parse) + .unwrap_or_default(), + ), + other => WalletNetwork::from_str(other), } } #[derive(Serialize)] pub struct WalletConfig { - /// "test" or "main". + /// "main", "test", or "regtest". pub network: String, pub lightwalletd_url: String, + /// Regtest: the forced upgrade level, or `None` for auto. + pub regtest_upgrade: Option, + /// Regtest: the upgrade level last detected from the node, if any. + pub regtest_detected: Option, + /// Regtest: the level actually in use (override, else detected, else NU6). + pub regtest_effective: Option, } /// Resolve the effective wallet config, filling in the network's default @@ -36,16 +53,22 @@ fn resolve_config(state: &AppState) -> WalletConfig { // Mainnet is the default: it is where the wallet is actually used, and the // mainnet guardrails (badge, confirmation modal, address checks) make the // active network unmissable. Testnet remains one click away in settings. - let network = s.wallet_network.clone().unwrap_or_else(|| "main".into()); - let net = network_from_str(&network); + let net = configured_network(&s); let lightwalletd_url = s .lightwalletd_url .clone() .filter(|u| !u.trim().is_empty()) .unwrap_or_else(|| net.default_lightwalletd().to_string()); + let regtest_effective = match net { + WalletNetwork::Regtest(level) => Some(level.as_str().to_string()), + _ => None, + }; WalletConfig { - network, + network: net.as_str().to_string(), lightwalletd_url, + regtest_upgrade: s.regtest_upgrade.clone(), + regtest_detected: s.regtest_detected.clone(), + regtest_effective, } } @@ -55,15 +78,26 @@ pub async fn get_wallet_config(state: State<'_, AppState>) -> AppResult, network: String, lightwalletd_url: String, + regtest_upgrade: Option, ) -> AppResult { let mut settings = state.load_settings(); - settings.wallet_network = Some(if network == "main" { "main" } else { "test" }.to_string()); + settings.wallet_network = Some(WalletNetwork::from_str(&network).as_str().to_string()); + settings.regtest_upgrade = match regtest_upgrade.as_deref().map(str::trim) { + None | Some("") | Some("auto") => None, + Some(level) => Some( + RegtestUpgrade::parse(level) + .ok_or_else(|| AppError::new("config", format!("unknown devnet upgrade level '{level}'")))? + .as_str() + .to_string(), + ), + }; let url = lightwalletd_url.trim(); // Refuse to persist a plaintext (non-loopback http://) endpoint so wallet // traffic is never silently downgraded off TLS. @@ -86,11 +120,12 @@ pub async fn lightwalletd_info( .filter(|u| !u.trim().is_empty()) .unwrap_or_else(|| resolve_config(&state).lightwalletd_url); let mut info = wallet::lightwalletd_info(&url).await?; + record_regtest_level(&state, &info)?; // Compute the consensus branch id this build would use at the node's height // and flag a mismatch (e.g. after a network upgrade like Ironwood the node // expects a branch id this build doesn't yet know), so the UI can warn // before a whole signing ceremony is spent on a tx the node will reject. - let network = network_from_str(&resolve_config(&state).network); + let network = configured_network(&state.load_settings()); let wallet_branch = wallet::branch_id_for_height(network, info.block_height); info.branch_supported = Some(!info.consensus_branch_id.is_empty() && info.consensus_branch_id == wallet_branch); @@ -98,6 +133,121 @@ pub async fn lightwalletd_info( Ok(info) } +/// What "Detect local devnet" found. +#[derive(Serialize)] +pub struct DevnetStatus { + /// The thus-spoke-zakura CLI is installed (Linux/macOS only). + pub installed: bool, + /// The devnet's lightwalletd answered and reports a regtest chain. + pub running: bool, + /// The devnet's lightwalletd URL (loopback, plaintext), when known. + pub lightwalletd: Option, + /// The devnet's browser dashboard (faucet, mining), when known. + pub dashboard: Option, + /// Chain height reported by the devnet's lightwalletd, when running. + pub block_height: Option, + /// The upgrade level read from the node (`nu6` … `nu6_3`), when running. + pub detected_upgrade: Option, + /// Why the devnet isn't usable yet, phrased as what to do next. + pub detail: Option, +} + +/// Find a local thus-spoke-zakura devnet and check that it's up. `instance` is +/// the launcher's `--name` (default `"default"`). Never changes the saved +/// endpoint — the UI fills the field and the user saves — but it does record +/// the devnet's upgrade level, like a connection test. +#[tauri::command] +pub async fn detect_local_devnet( + state: State<'_, AppState>, + instance: Option, +) -> AppResult { + use crate::devnet::{lookup, Lookup}; + let instance = instance + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "default".into()); + let mut status = DevnetStatus { + installed: false, + running: false, + lightwalletd: None, + dashboard: None, + block_height: None, + detected_upgrade: None, + detail: None, + }; + let endpoints = match lookup(&instance).await? { + Lookup::NotInstalled => { + status.detail = Some(if cfg!(target_os = "windows") { + "thus-spoke-zakura runs on Linux and macOS. On Windows, run it in WSL2 and enter its lightwalletd URL by hand.".into() + } else { + "thus-spoke-zakura isn't installed (looked on PATH and in ~/.local/bin).".into() + }); + return Ok(status); + } + Lookup::NoInstance(reason) => { + status.installed = true; + status.detail = Some(format!( + "No devnet named '{instance}' is set up ({reason}). Start one with `thus-spoke-zakura`." + )); + return Ok(status); + } + Lookup::Found(e) => e, + }; + status.installed = true; + status.dashboard = Some(endpoints.dashboard.clone()); + // The devnet binds to loopback only. Anything else isn't the devnet this + // detector is for, and must not be offered as a plaintext endpoint. + if !wallet::is_loopback_host(&endpoints.lightwalletd) { + status.detail = Some(format!( + "the devnet reported a non-local lightwalletd ({}); enter the endpoint by hand", + endpoints.lightwalletd + )); + return Ok(status); + } + status.lightwalletd = Some(endpoints.lightwalletd.clone()); + // `endpoints` reads a file that outlives the devnet, so probe it. + match wallet::lightwalletd_info(&endpoints.lightwalletd).await { + Ok(info) if info.chain_name == "regtest" => { + record_regtest_level(&state, &info)?; + status.running = true; + status.block_height = Some(info.block_height); + status.detected_upgrade = RegtestUpgrade::from_branch_id(&info.consensus_branch_id) + .map(|u| u.as_str().to_string()); + } + Ok(info) => { + status.detail = Some(format!( + "{} is a '{}' node, not a regtest devnet", + endpoints.lightwalletd, info.chain_name + )); + } + Err(e) => { + tracing::debug!(error = %e, url = %endpoints.lightwalletd, "devnet: probe failed"); + status.detail = Some(format!( + "Found devnet '{instance}', but it isn't running. Start it with `thus-spoke-zakura`." + )); + } + } + Ok(status) +} + +/// When a probed node is a regtest devnet, remember which upgrade it runs (read +/// from its consensus branch id) so "auto" builds for the same rules. Keyed on +/// what the node reports, not the saved network, so testing a devnet endpoint +/// before saving it still records the level. +pub(crate) fn record_regtest_level(state: &AppState, info: &LightwalletdInfo) -> AppResult<()> { + if info.chain_name != "regtest" { + return Ok(()); + } + let detected = RegtestUpgrade::from_branch_id(&info.consensus_branch_id).map(|u| u.as_str().to_string()); + let mut settings = state.load_settings(); + if settings.regtest_detected != detected { + tracing::info!(branch = %info.consensus_branch_id, level = ?detected, "devnet: detected upgrade level"); + settings.regtest_detected = detected; + state.save_settings(&settings)?; + } + Ok(()) +} + /// Resolve the wallet context for a RedPallas group: (network, lightwalletd /// URL, derived UFVK string). async fn group_wallet_ctx( @@ -120,7 +270,7 @@ async fn group_wallet_ctx( )); } let cfg = resolve_config(state); - let network = WalletNetwork::from_str(&cfg.network); + let network = configured_network(&state.load_settings()); // The group id is the hex of its verifying key (the Orchard ak). let ufvk = frost_app_core::zcash::derive_orchard_keys_hex(group_id, network.network_type())?.ufvk; Ok((network, cfg.lightwalletd_url, ufvk)) @@ -159,11 +309,15 @@ pub async fn wallet_init_account( let (network, url, ufvk) = group_wallet_ctx(&state, &group_id).await?; let db_key = state.wallet_db_key(&group_id).await?; - let recorded = state - .load_settings() - .wallet_birthdays - .get(&group_id) - .copied(); + // Recorded birthdays are keyed by group, not network, and are only ever + // public-chain heights. A devnet always scans its whole (tiny) chain — see + // `default_birthday_height` — and is reset often, so it neither reuses a + // recorded height (a testnet ~4.1M would hide every devnet note) nor + // records its own (which would clobber the group's real birthday). + let is_devnet = matches!(network, WalletNetwork::Regtest(_)); + let recorded = (!is_devnet) + .then(|| state.load_settings().wallet_birthdays.get(&group_id).copied()) + .flatten(); let requested = birthday_height.or(recorded); let scan_from = wallet::init_group_account( @@ -180,7 +334,7 @@ pub async fn wallet_init_account( // Remember where this wallet starts, so a later rebuild of the (deleted) // wallet database scans from here again rather than from the chain tip. // `scan_from == 0` means the account already existed; nothing was imported. - if scan_from > 0 && recorded != Some(scan_from) { + if scan_from > 0 && !is_devnet && recorded != Some(scan_from) { let mut settings = state.load_settings(); settings.wallet_birthdays.insert(group_id, scan_from); state.save_settings(&settings)?; @@ -467,10 +621,18 @@ pub async fn resolve_zns_name( state: State<'_, AppState>, name: String, ) -> AppResult> { - let mainnet = matches!( - network_from_str(&resolve_config(&state).network), - WalletNetwork::Main - ); + let mainnet = match configured_network(&state.load_settings()) { + WalletNetwork::Main => true, + WalletNetwork::Test => false, + // There is no ZNS indexer for a local chain. Say so, rather than + // returning `None`, which the UI would show as "name not registered". + WalletNetwork::Regtest(_) => { + return Err(AppError::new( + "config", + "ZcashNames aren't available on the local devnet — paste a uregtest1… address instead", + )) + } + }; Ok(frost_app_core::zns::resolve_name(&name, mainnet).await?) } @@ -602,7 +764,7 @@ pub async fn wallet_send( let ctx_fee = draft.fee_zatoshis; let ctx_memo = draft.memo.clone(); let ctx_is_unshield = draft.is_unshield; - let ctx_network = if matches!(network, WalletNetwork::Main) { "main" } else { "test" }.to_string(); + let ctx_network = network.as_str().to_string(); let plan_id = ceremony_id.to_string(); tauri::async_runtime::spawn(async move { let fail = |error: String| { diff --git a/src-tauri/src/devnet.rs b/src-tauri/src/devnet.rs new file mode 100644 index 0000000..4976734 --- /dev/null +++ b/src-tauri/src/devnet.rs @@ -0,0 +1,167 @@ +//! Find a local thus-spoke-zakura regtest devnet. +//! +//! [thus-spoke-zakura](https://github.com/zcashlabs/thus-spoke-zakura) runs a +//! Zakura regtest node and lightwalletd in Docker, publishing each on a random +//! loopback port. Its CLI reports them with `thus-spoke-zakura endpoints --json`: +//! +//! ```json +//! { "dashboard": "http://127.0.0.1:…", "rpc": "…", "lightwalletd": "http://127.0.0.1:…", "p2p": "…" } +//! ``` +//! +//! Like Tailscale, this **drives a system CLI** that the user installs; nothing +//! is bundled. The CLI reads those endpoints from a saved `instance.json` that +//! outlives the devnet, so a result here says where the devnet *was*. The +//! command layer probes the lightwalletd URL before calling it running. + +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; + +use serde::Deserialize; +use tokio::process::Command; + +use crate::error::{AppError, AppResult}; + +/// The launcher's binary name. +const BIN: &str = "thus-spoke-zakura"; + +/// How long any one CLI call may take. `endpoints` only reads a file, so this +/// is generous; it just keeps a wedged CLI from hanging the settings screen. +const CLI_TIMEOUT: Duration = Duration::from_secs(10); + +/// Endpoints of a devnet instance, as printed by `endpoints --json`. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct Endpoints { + pub dashboard: String, + pub rpc: String, + pub lightwalletd: String, + pub p2p: String, +} + +/// What looking for the devnet found, before any network probe. +#[derive(Debug)] +pub enum Lookup { + /// The CLI isn't on this machine (or this OS isn't supported by it). + NotInstalled, + /// The CLI ran but has no endpoints for this instance — it was never + /// started, or it was stopped and deleted. Carries the CLI's own message. + NoInstance(String), + Found(Endpoints), +} + +/// Candidate binary locations: PATH first, then where `install.sh` puts it. +/// A GUI-launched app often doesn't inherit a shell PATH that includes +/// `~/.local/bin`, so the install locations are tried explicitly. +fn candidates() -> Vec { + let mut c = vec![PathBuf::from(BIN)]; + if let Some(dir) = std::env::var_os("TSZ_INSTALL_DIR") { + c.push(PathBuf::from(dir).join(BIN)); + } + if let Some(home) = dirs::home_dir() { + c.push(home.join(".local/bin").join(BIN)); + } + c.push(PathBuf::from("/usr/local/bin").join(BIN)); + c.push(PathBuf::from("/opt/homebrew/bin").join(BIN)); + c +} + +/// The first candidate that answers `--version`, or `None` if not installed. +async fn resolve_bin() -> Option { + for bin in candidates() { + let mut cmd = Command::new(&bin); + cmd.arg("--version").stdout(Stdio::null()).stderr(Stdio::null()); + let ok = tokio::time::timeout(CLI_TIMEOUT, cmd.status()) + .await + .ok() + .and_then(Result::ok) + .is_some_and(|s| s.success()); + if ok { + return Some(bin); + } + } + None +} + +/// The launcher's instance-name rule: 1–40 lowercase letters, digits, or +/// internal hyphens. Checked here too so a name can never be read as a flag. +pub fn valid_instance_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 40 + && !name.starts_with('-') + && !name.ends_with('-') + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +/// Parse `endpoints --json` output. +pub fn parse_endpoints(stdout: &[u8]) -> AppResult { + serde_json::from_slice(stdout) + .map_err(|e| AppError::new("devnet", format!("unexpected `{BIN} endpoints --json` output: {e}"))) +} + +/// Ask the CLI where the devnet named `instance` is. +pub async fn lookup(instance: &str) -> AppResult { + if cfg!(target_os = "windows") { + // install.sh supports Linux and macOS only. + return Ok(Lookup::NotInstalled); + } + if !valid_instance_name(instance) { + return Err(AppError::new( + "devnet", + "instance names use 1-40 lowercase letters, digits, or internal hyphens", + )); + } + let Some(bin) = resolve_bin().await else { + return Ok(Lookup::NotInstalled); + }; + let mut cmd = Command::new(&bin); + cmd.args(["--name", instance, "--json", "endpoints"]) + .stdin(Stdio::null()); + let out = tokio::time::timeout(CLI_TIMEOUT, cmd.output()) + .await + .map_err(|_| AppError::new("devnet", format!("`{BIN} endpoints` timed out")))? + .map_err(|e| AppError::new("devnet", format!("running `{BIN} endpoints`: {e}")))?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + let reason = stderr + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .unwrap_or("no endpoints recorded") + .to_string(); + return Ok(Lookup::NoInstance(reason)); + } + Ok(Lookup::Found(parse_endpoints(&out.stdout)?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_the_launchers_endpoints_json() { + let json = br#"{ + "dashboard": "http://127.0.0.1:49153", + "rpc": "http://127.0.0.1:49154", + "lightwalletd": "http://127.0.0.1:49155", + "p2p": "127.0.0.1:49156" + }"#; + let e = parse_endpoints(json).unwrap(); + assert_eq!(e.lightwalletd, "http://127.0.0.1:49155"); + assert_eq!(e.dashboard, "http://127.0.0.1:49153"); + assert!(parse_endpoints(b"not json").is_err()); + assert!(parse_endpoints(br#"{"dashboard":"x"}"#).is_err()); + } + + #[test] + fn instance_names_follow_the_launchers_rule() { + assert!(valid_instance_name("default")); + assert!(valid_instance_name("my-devnet-2")); + assert!(!valid_instance_name("")); + assert!(!valid_instance_name("--json")); + assert!(!valid_instance_name("trailing-")); + assert!(!valid_instance_name("Upper")); + assert!(!valid_instance_name(&"a".repeat(41))); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c608103..1975a73 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ pub mod commands; +pub mod devnet; pub mod error; pub mod logbuf; pub mod sidecar; @@ -82,6 +83,7 @@ pub fn run() { commands::wallet::get_wallet_config, commands::wallet::set_wallet_config, commands::wallet::lightwalletd_info, + commands::wallet::detect_local_devnet, commands::wallet::wallet_group_status, commands::wallet::wallet_init_account, commands::wallet::wallet_sync, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index a59a6b2..66f3c15 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -43,9 +43,18 @@ pub struct Settings { /// Local nicknames/aliases for contacts, keyed by hex comm pubkey. #[serde(default)] pub contact_aliases: HashMap, - /// Zcash network for wallet features: "test" (default) or "main". + /// Zcash network for wallet features: "main" (default), "test", or + /// "regtest" (a local devnet). #[serde(default)] pub wallet_network: Option, + /// Regtest only: the upgrade level the user forced ("nu6" … "nu6_3"). + /// `None` means auto — use [`Settings::regtest_detected`]. + #[serde(default)] + pub regtest_upgrade: Option, + /// Regtest only: the upgrade level last read from the devnet node's + /// consensus branch id (recorded by the connection test). + #[serde(default)] + pub regtest_detected: Option, /// lightwalletd endpoint for the selected network. #[serde(default)] pub lightwalletd_url: Option, From 503042f42975098fc9fb90ce2f940e3d59a96288 Mon Sep 17 00:00:00 2001 From: blocknodes Date: Fri, 18 Sep 2026 03:24:31 +0000 Subject: [PATCH 3/4] feat(ui): Local devnet network in Wallet settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wallet settings: a third "Local devnet" network with a devnet panel — "Detect local devnet" (fills the endpoint, links the faucet/mining dashboard, or shows the install command), a network-upgrade select (Auto = read from the node, or force NU6…NU6.3), and a warning when the chain is below NU6.2, where sends will likely be rejected. - Shared src/network.ts helpers (label, ticker, upgrade names) replace the duplicated unit() helpers and the isMainnet boolean threaded through the group wallet. - Devnet amounts use a distinct rZEC ticker, including in the approver's Inbox, which previously showed "ZEC" for testnet requests too. - Header pill reads Mainnet / Testnet / Devnet; recipient validation and placeholders know the uregtest1… prefix. - On a pre-Ironwood devnet the headline balance is the Orchard pool (the live pool there), and the "move legacy funds" prompt is hidden. Mainnet and testnet stay strictly Ironwood. Co-Authored-By: Claude Opus 5 --- src/ipc/commands.ts | 31 +++- src/network.ts | 49 +++++++ src/screens/Groups.tsx | 202 ++++++++++++++------------ src/screens/Inbox.tsx | 15 +- src/screens/Wallet.tsx | 312 +++++++++++++++++++++++++++++++++------- src/screens/Wallets.tsx | 15 +- src/styles.css | 19 +++ 7 files changed, 488 insertions(+), 155 deletions(-) create mode 100644 src/network.ts diff --git a/src/ipc/commands.ts b/src/ipc/commands.ts index 9daf048..f136345 100644 --- a/src/ipc/commands.ts +++ b/src/ipc/commands.ts @@ -116,9 +116,29 @@ export const setUsername = (username: string) => invoke("set_username", { username }); // Zcash wallet (light-client) +/** The Zcash network the wallet runs on. "regtest" is a local devnet. */ +export type WalletNetwork = "main" | "test" | "regtest"; +/** A local devnet's newest active upgrade (all activated from genesis). */ +export type RegtestUpgrade = "nu6" | "nu6_1" | "nu6_2" | "nu6_3"; export interface WalletConfig { - network: string; // "test" | "main" + network: WalletNetwork; lightwalletd_url: string; + /** Devnet: the forced upgrade level, or null for auto. */ + regtest_upgrade: RegtestUpgrade | null; + /** Devnet: the level last read from the node, if any. */ + regtest_detected: RegtestUpgrade | null; + /** Devnet: the level in use (override, else detected, else NU6). */ + regtest_effective: RegtestUpgrade | null; +} +/** What "Detect local devnet" found. */ +export interface DevnetStatus { + installed: boolean; + running: boolean; + lightwalletd: string | null; + dashboard: string | null; + block_height: number | null; + detected_upgrade: RegtestUpgrade | null; + detail: string | null; } export interface LightwalletdInfo { chain_name: string; @@ -134,8 +154,13 @@ export interface LightwalletdInfo { branch_supported: boolean | null; } export const getWalletConfig = () => invoke("get_wallet_config"); -export const setWalletConfig = (network: string, lightwalletdUrl: string) => - invoke("set_wallet_config", { network, lightwalletdUrl }); +export const setWalletConfig = ( + network: WalletNetwork, + lightwalletdUrl: string, + regtestUpgrade: RegtestUpgrade | null = null +) => invoke("set_wallet_config", { network, lightwalletdUrl, regtestUpgrade }); +export const detectLocalDevnet = (instance: string | null = null) => + invoke("detect_local_devnet", { instance }); export const lightwalletdInfo = (url: string | null) => invoke("lightwalletd_info", { url }); diff --git a/src/network.ts b/src/network.ts new file mode 100644 index 0000000..1583899 --- /dev/null +++ b/src/network.ts @@ -0,0 +1,49 @@ +import type { RegtestUpgrade, WalletNetwork } from "./ipc/commands"; + +/** Coerce the backend's network string; anything unknown is treated as testnet, + * matching the backend's own fallback. */ +export function asNetwork(n: string | null | undefined): WalletNetwork { + return n === "main" || n === "regtest" ? n : "test"; +} + +export const isMainnet = (n: WalletNetwork) => n === "main"; + +/** Short user-facing name, used for the network pill and "you are on …" hints. */ +export function networkLabel(n: WalletNetwork): string { + switch (n) { + case "main": + return "Mainnet"; + case "regtest": + return "Devnet"; + default: + return "Testnet"; + } +} + +/** Currency ticker: ZEC on mainnet, TAZ on testnet, rZEC on a local devnet — + * a distinct ticker so a devnet amount can never be mistaken for real ZEC. */ +export function unit(n: WalletNetwork): string { + switch (n) { + case "main": + return "ZEC"; + case "regtest": + return "rZEC"; + default: + return "TAZ"; + } +} + +export const REGTEST_UPGRADES: RegtestUpgrade[] = ["nu6", "nu6_1", "nu6_2", "nu6_3"]; + +/** "nu6_3" -> "NU6.3 (Ironwood)". */ +export function upgradeLabel(u: RegtestUpgrade): string { + const base = u.toUpperCase().replace("_", "."); + return u === "nu6_3" ? `${base} (Ironwood)` : base; +} + +/** Orchard sends need the NU6.2+ circuit (the only one the orchard crate can + * prove with), so a devnet below NU6.2 will likely reject them. */ +export const devnetCanSend = (u: RegtestUpgrade | null) => u === "nu6_2" || u === "nu6_3"; + +/** Ironwood is the active shielded pool only from NU6.3. */ +export const devnetHasIronwood = (u: RegtestUpgrade | null) => u === "nu6_3"; diff --git a/src/screens/Groups.tsx b/src/screens/Groups.tsx index f8bc3e5..1f79fc7 100644 --- a/src/screens/Groups.tsx +++ b/src/screens/Groups.tsx @@ -33,7 +33,9 @@ import { ContactDto, GroupSummary, Identity, + WalletNetwork, } from "../ipc/commands"; +import { asNetwork, devnetHasIronwood, networkLabel, unit } from "../network"; import { resolveParticipant } from "../lib/participants"; import { useCeremonies, @@ -46,10 +48,6 @@ function zec(zats: number): string { return (zats / 1e8).toLocaleString(undefined, { maximumFractionDigits: 8 }); } -/** The currency ticker for the active network: ZEC on mainnet, TAZ on testnet. */ -function unit(isMainnet: boolean): string { - return isMainnet ? "ZEC" : "TAZ"; -} /** How long an unconfirmed send is still worth showing. A transaction's expiry * is ~40 blocks (≈50 min), so past an hour with no on-chain row it can never @@ -164,17 +162,31 @@ function MainnetConfirmModal({ type SendMode = "shielded" | "unshield"; -/** Network a transparent address belongs to (mainnet t1/t3 P2PKH/P2SH, - * testnet tm/t2), or null if not a transparent address. */ -function transparentNet(a: string): "main" | "test" | null { +const RECIPIENT_PLACEHOLDER: Record> = { + unshield: { + main: "t1… or t3… (mainnet transparent)", + test: "tm… or t2… (testnet transparent)", + regtest: "tm… (devnet transparent)", + }, + shielded: { + main: "u1… (mainnet)", + test: "utest1… (testnet)", + regtest: "uregtest1… (devnet)", + }, +}; + +/** Network a transparent address belongs to (mainnet t1/t3 P2PKH/P2SH, or + * tm/t2 — shared by testnet and regtest), or null if not a transparent address. */ +function transparentNet(a: string): WalletNetwork | "test-or-regtest" | null { if (a.startsWith("t1") || a.startsWith("t3")) return "main"; - if (a.startsWith("tm") || a.startsWith("t2")) return "test"; + if (a.startsWith("tm") || a.startsWith("t2")) return "test-or-regtest"; return null; } -/** Network a shielded address belongs to (UA u1/utest1, Sapling zs1/ztestsapling), - * or null if not a shielded address. */ -function shieldedNet(a: string): "main" | "test" | null { +/** Network a shielded address belongs to (UA u1/utest1/uregtest1, Sapling + * zs1/ztestsapling/zregtestsapling), or null if not a shielded address. */ +function shieldedNet(a: string): WalletNetwork | null { + if (a.startsWith("uregtest") || a.startsWith("zregtestsapling")) return "regtest"; if (a.startsWith("utest") || a.startsWith("ztestsapling")) return "test"; if (a.startsWith("u1") || a.startsWith("zs1")) return "main"; return null; @@ -186,24 +198,25 @@ function shieldedNet(a: string): "main" | "test" | null { * Address::decode stays authoritative on submit; this is fast guidance. */ function validateRecipient( address: string, - isMainnet: boolean, + network: WalletNetwork, mode: SendMode ): string | null { const a = address.trim(); if (!a) return null; const t = transparentNet(a); const sh = shieldedNet(a); - const wrongNet = (net: "main" | "test" | null) => - (isMainnet && net === "test") || (!isMainnet && net === "main"); + const here = networkLabel(network); if (mode === "unshield") { if (sh) { return "This is a shielded address — switch to Send for shielded recipients. Unshield needs a transparent t-address."; } - if (t && wrongNet(t)) { - return isMainnet - ? "This looks like a testnet transparent address. You are on Mainnet." - : "This looks like a mainnet transparent address. You are on Testnet."; + // tm/t2 is valid on both testnet and regtest, so only a clear mismatch is flagged. + if (t === "main" && network !== "main") { + return `This looks like a mainnet transparent address. You are on ${here}.`; + } + if (t === "test-or-regtest" && network === "main") { + return "This looks like a testnet transparent address. You are on Mainnet."; } return null; } @@ -211,10 +224,10 @@ function validateRecipient( if (t) { return "This is a transparent address — switch to Unshield to send to a transparent t-address."; } - if (sh && wrongNet(sh)) { - return isMainnet - ? "This looks like a testnet address. You are on Mainnet — check the address carefully." - : "This looks like a mainnet address. You are on Testnet."; + if (sh && sh !== network) { + return `This looks like a ${networkLabel(sh).toLowerCase()} address. You are on ${here}${ + network === "main" ? " — check the address carefully" : "" + }.`; } return null; } @@ -335,13 +348,13 @@ type WalletTab = "receive" | "send" | "notes" | "vote"; * a one-click self-send consolidation to merge fragmented notes into one. */ function ReviewNotesTab({ groupId, - isMainnet, + network, onConsolidate, consolidatePending, disabledReason, }: { groupId: string; - isMainnet: boolean; + network: WalletNetwork; onConsolidate: () => void; consolidatePending: boolean; disabledReason: string | null; @@ -381,7 +394,7 @@ function ReviewNotesTab({ Your balance is made of {spendable.length} spendable note{spendable.length === 1 ? "" : "s"} {pending.length > 0 && <> (plus {pending.length} pending)}, totalling{" "} - {zec(totalSpendable)} {unit(isMainnet)} spendable. Each note is a + {zec(totalSpendable)} {unit(network)} spendable. Each note is a separate spend authorization, so a full-balance send needs{" "} {rounds} signing round{rounds === 1 ? "" : "s"} @@ -432,7 +445,7 @@ function ReviewNotesTab({ return ( - {zec(n.value_zatoshis)} {unit(isMainnet)} + {zec(n.value_zatoshis)} {unit(network)} {b.label} @@ -496,12 +509,12 @@ function parseBallot( * the vote is a shielded memo sent via the normal review + FROST ceremony. Vote * weight is set by the poll's balance snapshot, not the amount sent. */ function VoteTab({ - isMainnet, + network, disabledReason, pending, onCastVote, }: { - isMainnet: boolean; + network: WalletNetwork; disabledReason: string | null; pending: boolean; onCastVote: (v: { @@ -633,7 +646,7 @@ function VoteTab({ ); })} - +
(null); const [walletTab, setWalletTab] = useState("receive"); @@ -785,7 +806,7 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo const mustResolveName = recipientLooksLikeName && !znsResult; const recipientErr = mustResolveName ? null - : validateRecipient(effectiveRecipient, isMainnet, sendMode); + : validateRecipient(effectiveRecipient, network, sendMode); // Balances are read from the local wallet database, which is empty (or stale) // until the first sync finishes. Building a transaction before then selects // from notes the wallet hasn't scanned yet and fails with a spurious @@ -837,7 +858,7 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo const spendable = status.data?.spendable_zatoshis ?? 0; if (!addr) throw new Error("wallet address not available — try syncing first"); if (spendable <= CONSOLIDATE_FEE_BUFFER) - throw new Error(`balance too low to consolidate (need > 0.001 ${unit(isMainnet)} above fees)`); + throw new Error(`balance too low to consolidate (need > 0.001 ${unit(network)} above fees)`); return walletPrepareSend(group.id, addr, spendable - CONSOLIDATE_FEE_BUFFER); }, onSuccess: (d) => { @@ -866,7 +887,7 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo if (!addr) throw new Error("wallet address not available — try syncing first"); if (orchard <= CONSOLIDATE_FEE_BUFFER) throw new Error( - `Legacy balance too low to move (need > 0.001 ${unit(isMainnet)} above fees)` + `Legacy balance too low to move (need > 0.001 ${unit(network)} above fees)` ); return walletPrepareSend(group.id, addr, orchard - CONSOLIDATE_FEE_BUFFER); }, @@ -1104,34 +1125,42 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo ) : ( <> - {/* Balance summary — the group's Ironwood balance, the pool all new - shielded value lands in. Any legacy Orchard balance is surfaced - separately below, only when it exists. */} + {/* Balance summary — the group's balance in the live pool: Ironwood, + where all new shielded value lands (or Orchard on a pre-Ironwood + devnet). Any legacy Orchard balance is surfaced separately below, + only when it exists. */}
- {zec(s.ironwood.spendable_zatoshis)} {unit(isMainnet)} + {zec(s[livePool].spendable_zatoshis)} {unit(network)}
- {zec(s.ironwood.pending_zatoshis)} {unit(isMainnet)} + {zec(s[livePool].pending_zatoshis)} {unit(network)}
- {zec(s.ironwood.total_zatoshis)} {unit(isMainnet)} + {zec(s[livePool].total_zatoshis)} {unit(network)}
+ {livePool === "orchard" && ( +
+ Orchard pool — Ironwood isn't active on this devnet. +
+ )} {/* Legacy Orchard funds: only surfaced when a spendable balance actually remains, with a one-tap sweep into Ironwood. New groups - never hold Orchard, so this stays hidden for them. */} - {s.orchard.spendable_zatoshis > CONSOLIDATE_FEE_BUFFER && + never hold Orchard, so this stays hidden for them — as it does + on a pre-Ironwood devnet, where there is no Ironwood to move to. */} + {livePool === "ironwood" && + s.orchard.spendable_zatoshis > CONSOLIDATE_FEE_BUFFER && !(activeSend && !activeSend.done) && (
Move legacy funds to Ironwood. This group holds{" "} - {zec(s.orchard.spendable_zatoshis)} {unit(isMainnet)} in the old + {zec(s.orchard.spendable_zatoshis)} {unit(network)} in the old pool. Sweep it across so all funds stay spendable.
)} - + 0 ? ( <> {" "} - {zec(pendingBalance)} {unit(isMainnet)} is still awaiting + {zec(pendingBalance)} {unit(network)} is still awaiting confirmations — received funds need 10 confirmations (your own change needs 3) before they can be spent. @@ -1461,7 +1482,7 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo )} {initialSyncDone && !noSpendableBalance && exceedsBalance && (

- You can spend at most {zec(spendableBalance)} {unit(isMainnet)}{" "} + You can spend at most {zec(spendableBalance)} {unit(network)}{" "} right now (a network fee is taken on top of the amount).

)} @@ -1494,7 +1515,7 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo Coinholder-poll vote — sends a shielded memo (your encoded answers) to the poll's reception address. Vote weight comes from the poll's balance snapshot, not the{" "} - {zec(draft.amount_zatoshis)} {unit(isMainnet)}{" "} + {zec(draft.amount_zatoshis)} {unit(network)}{" "} dust sent with it. {draft.spends.length} note {draft.spends.length !== 1 ? "s" : ""} will be signed. Keep the group's balance through the poll's snapshot window to be counted. @@ -1523,7 +1544,7 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo {!isConsolidation && draft.is_unshield && (
- Unshield — moves {zec(draft.amount_zatoshis)} {unit(isMainnet)} to + Unshield — moves {zec(draft.amount_zatoshis)} {unit(network)} to a transparent address, so the amount and recipient will be{" "} public on-chain. @@ -1539,15 +1560,15 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo Amount to send - {zec(draft.amount_zatoshis)} {unit(isMainnet)} + {zec(draft.amount_zatoshis)} {unit(network)} Fee - {zec(draft.fee_zatoshis)} {unit(isMainnet)} + {zec(draft.fee_zatoshis)} {unit(network)} Total - {zec(draft.amount_zatoshis + draft.fee_zatoshis)} {unit(isMainnet)} + {zec(draft.amount_zatoshis + draft.fee_zatoshis)} {unit(network)} Sighash @@ -1629,7 +1650,7 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo

- +
- +
); } @@ -2536,7 +2562,7 @@ export function GroupWalletPage() { * * Columns: Date & Time | Type | Amount | Address | Tx Hash | [+] */ -function WalletTxHistory({ group, isMainnet }: { group: GroupSummary; isMainnet: boolean }) { +function WalletTxHistory({ group, network }: { group: GroupSummary; network: WalletNetwork }) { const history = useQuery({ queryKey: ["wallet-history", group.id], queryFn: () => walletHistory(group.id), @@ -2651,7 +2677,7 @@ function WalletTxHistory({ group, isMainnet }: { group: GroupSummary; isMainnet: myPubkey={identity.data?.pubkey ?? undefined} isExpanded={expandedKey === row.id} onToggle={() => toggle(row.id)} - isMainnet={isMainnet} + network={network} /> ))} {/* On-chain confirmed rows from SQLite */} @@ -2665,7 +2691,7 @@ function WalletTxHistory({ group, isMainnet }: { group: GroupSummary; isMainnet: myPubkey={identity.data?.pubkey ?? undefined} isExpanded={expandedKey === tx.txid} onToggle={() => toggle(tx.txid)} - isMainnet={isMainnet} + network={network} /> ))} @@ -2697,7 +2723,7 @@ function TxDetail({ contacts, myPubkey, error, - isMainnet, + network, }: { colSpan: number; txid?: string; @@ -2713,7 +2739,7 @@ function TxDetail({ contacts?: ContactDto[]; myPubkey?: string; error?: string; - isMainnet: boolean; + network: WalletNetwork; }) { const dateStr = timestamp ? fmtDate(new Date(timestamp * 1000)) @@ -2727,7 +2753,7 @@ function TxDetail({ if (blockHeight != null) rows.push({ label: "Block", value: `#${blockHeight.toLocaleString()}` }); if (txid) rows.push({ label: "Transaction ID", value: txid, mono: true }); if (direction) rows.push({ label: "Type", value: direction === "receive" ? "Received" : "Sent" }); - if (amount != null) rows.push({ label: "Amount", value: `${direction === "receive" ? "+" : "−"}${zec(amount)} ${unit(isMainnet)}` }); + if (amount != null) rows.push({ label: "Amount", value: `${direction === "receive" ? "+" : "−"}${zec(amount)} ${unit(network)}` }); // Always report the fee, for every transaction type. It used to be omitted // whenever it was unknown, which is precisely the received case — leaving no // indication of whether a fee existed at all. A received transaction's fee was @@ -2737,7 +2763,7 @@ function TxDetail({ label: "Network Fee", value: fee != null - ? `${zec(fee)} ${unit(isMainnet)}` + ? `${zec(fee)} ${unit(network)}` : direction === "receive" ? "Paid by the sender (not visible to this wallet)" : "—", @@ -2842,7 +2868,7 @@ function PendingTxRow({ myPubkey, isExpanded, onToggle, - isMainnet, + network, }: { row: PendingRow; contacts: ContactDto[]; @@ -2850,7 +2876,7 @@ function PendingTxRow({ myPubkey?: string; isExpanded: boolean; onToggle: () => void; - isMainnet: boolean; + network: WalletNetwork; }) { const meta = row.send; const isUnshield = meta?.isUnshield; @@ -2874,7 +2900,7 @@ function PendingTxRow({ )} - {meta ? `−${zec(meta.amountZatoshis)} ${unit(isMainnet)}` : "—"} + {meta ? `−${zec(meta.amountZatoshis)} ${unit(network)}` : "—"} {addrDisplay} @@ -2900,7 +2926,7 @@ function PendingTxRow({ {isExpanded && ( void; - isMainnet: boolean; + network: WalletNetwork; }) { const isReceive = tx.direction === "receive"; const addrDisplay = isReceive @@ -2965,7 +2991,7 @@ function OnchainTxRow({ {isReceive ? "+" : "−"} - {zec(tx.amount_zatoshis)} {unit(isMainnet)} + {zec(tx.amount_zatoshis)} {unit(network)} @@ -2989,7 +3015,7 @@ function OnchainTxRow({ {isExpanded && ( = { @@ -45,12 +46,14 @@ interface SigningContext { tx_sighash?: string; } -/** Format zatoshis as a ZEC amount (1 ZEC = 100,000,000 zatoshis). */ -function formatZec(zatoshis: number): string { +/** Format zatoshis in the request's network ticker (1 unit = 100,000,000 + * zatoshis): ZEC, TAZ, or rZEC — so an approver never mistakes a test or + * devnet request for real ZEC. */ +function formatZec(zatoshis: number, network: string): string { return `${(zatoshis / 1e8).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 8, - })} ZEC`; + })} ${unit(asNetwork(network))}`; } export default function Inbox() { @@ -216,12 +219,12 @@ export default function Inbox() { Amount - {formatZec(txContext.amount_zatoshis)} + {formatZec(txContext.amount_zatoshis, txContext.network)} Fee - {formatZec(txContext.fee_zatoshis)} + {formatZec(txContext.fee_zatoshis, txContext.network)} To @@ -231,7 +234,7 @@ export default function Inbox() { Network - {txContext.network === "main" ? "Mainnet" : "Testnet"} + {networkLabel(asNetwork(txContext.network))} {txContext.memo && ( diff --git a/src/screens/Wallet.tsx b/src/screens/Wallet.tsx index 664b7ec..c1e565a 100644 --- a/src/screens/Wallet.tsx +++ b/src/screens/Wallet.tsx @@ -4,42 +4,80 @@ import { getWalletConfig, lightwalletdInfo, setWalletConfig, + detectLocalDevnet, + openUrl, getLogs, clearLogs, AppError, + DevnetStatus, LightwalletdInfo, + RegtestUpgrade, + WalletNetwork, } from "../ipc/commands"; +import { REGTEST_UPGRADES, devnetCanSend, upgradeLabel } from "../network"; -/** Known public lightwalletd endpoints per network (user can also type their own). */ -const PRESETS: Record = { +/** Known lightwalletd endpoints per network (user can also type their own). */ +const PRESETS: Record = { test: [ { label: "zec.rocks — testnet", url: "https://testnet.zec.rocks:443" }, { label: "tz.ombie.cash", url: "https://tz.ombie.cash:443" }, { label: "tl.ombie.cash", url: "https://tl.ombie.cash:443" }, ], main: [{ label: "zec.rocks", url: "https://zec.rocks:443" }], + // A devnet picks its own port; "Detect local devnet" fills the real one. + regtest: [], }; +const PLACEHOLDER: Record = { + main: "https://zec.rocks:443", + test: "https://testnet.zec.rocks:443", + regtest: "http://127.0.0.1:", +}; + +const NETWORKS: { id: WalletNetwork; label: string; blurb: string }[] = [ + { id: "test", label: "Testnet", blurb: "Test network — faucet funds." }, + { id: "main", label: "Mainnet", blurb: "Live network — real ZEC." }, + { id: "regtest", label: "Local devnet", blurb: "Your own regtest chain — for development." }, +]; + +const INSTALL_CMD = + "curl --proto '=https' --tlsv1.2 -fsSL https://raw.githubusercontent.com/zcashlabs/thus-spoke-zakura/main/install.sh | sh"; + export default function Wallet() { const queryClient = useQueryClient(); const config = useQuery({ queryKey: ["wallet-config"], queryFn: getWalletConfig }); - const [network, setNetwork] = useState(null); + const [network, setNetwork] = useState(null); const [url, setUrl] = useState(null); + /** Pending devnet upgrade choice: "auto", a forced level, or null = unchanged. */ + const [upgrade, setUpgrade] = useState(null); const [info, setInfo] = useState(null); const [testErr, setTestErr] = useState(null); const [testing, setTesting] = useState(false); + const [devnet, setDevnet] = useState(null); + const [detecting, setDetecting] = useState(false); // Mainnet is the default (matches the backend), so the app opens on the network // it is actually used on rather than quietly pointing at testnet. - const net = network ?? config.data?.network ?? "main"; + const net: WalletNetwork = network ?? config.data?.network ?? "main"; const effectiveUrl = url ?? config.data?.lightwalletd_url ?? ""; + const isDevnet = net === "regtest"; + const detected = config.data?.regtest_detected ?? null; + const upgradeChoice = upgrade ?? config.data?.regtest_upgrade ?? "auto"; + const effectiveUpgrade: RegtestUpgrade = + upgradeChoice === "auto" ? detected ?? "nu6" : upgradeChoice; const save = useMutation({ - mutationFn: () => setWalletConfig(net, url ?? effectiveUrl), + mutationFn: () => + setWalletConfig( + net, + url ?? effectiveUrl, + isDevnet && upgradeChoice !== "auto" ? upgradeChoice : null + ), onSuccess: (cfg) => { setNetwork(null); setUrl(null); + setUpgrade(null); setInfo(null); queryClient.setQueryData(["wallet-config"], cfg); // Each network has its own wallet db, so balances/notes/history/addresses @@ -63,6 +101,8 @@ export default function Wallet() { setInfo(null); try { setInfo(await lightwalletdInfo(effectiveUrl || null)); + // A devnet probe records the node's upgrade level; show it. + queryClient.invalidateQueries({ queryKey: ["wallet-config"] }); } catch (e) { setTestErr((e as AppError).message ?? String(e)); } finally { @@ -70,7 +110,27 @@ export default function Wallet() { } }; - const isMainnet = net === "main"; + const detect = async () => { + setDetecting(true); + setDevnet(null); + setTestErr(null); + try { + const d = await detectLocalDevnet(); + setDevnet(d); + if (d.running && d.lightwalletd) setUrl(d.lightwalletd); + queryClient.invalidateQueries({ queryKey: ["wallet-config"] }); + } catch (e) { + setTestErr((e as AppError).message ?? String(e)); + } finally { + setDetecting(false); + } + }; + + const unsaved = + network !== null || url !== null || (upgrade !== null && upgrade !== (config.data?.regtest_upgrade ?? "auto")); + // A devnet reports "regtest"; its branch check is the devnet card's job + // (the generic mismatch text below is about public-network upgrades). + const infoIsDevnet = info?.chain_name === "regtest"; return (
@@ -84,67 +144,88 @@ export default function Wallet() {

Network

-
- - - - {isMainnet ? "Live network — real ZEC." : "Test network — faucet funds."} - +
+ {NETWORKS.map((n) => ( + + ))} + {NETWORKS.find((n) => n.id === net)?.blurb}
+ {isDevnet && ( + + )} + - + {PRESETS[net].length > 0 && ( + + )} setUrl(e.target.value)} />

- Pick a server above or type your own (a bare host:443{" "} - works too). + {isDevnet ? ( + <> + Plain http:// is allowed only for{" "} + 127.0.0.1 / localhost. + + ) : ( + <> + Pick a server above or type your own (a bare{" "} + host:443 works too). + + )}

-
+
+ {unsaved && Unsaved changes}
+ {save.error && ( +
+ {(save.error as unknown as AppError).message ?? String(save.error)} +
+ )} {info && (
@@ -162,7 +243,7 @@ export default function Wallet() {
)} - {info && info.branch_supported === false && ( + {info && !infoIsDevnet && info.branch_supported === false && (
⚠ Network upgrade mismatch — sends will be rejected.{" "} @@ -178,6 +259,14 @@ export default function Wallet() {
)} + {info && infoIsDevnet && !isDevnet && ( +
+ + This endpoint is a local devnet. Select Local devnet above + before saving it. + +
+ )} {testErr &&
{testErr}
}
@@ -186,6 +275,129 @@ export default function Wallet() { ); } +/** Local-devnet controls: find a running thus-spoke-zakura devnet, and choose + * which network upgrade its chain runs (auto-read from the node by default). */ +function DevnetPanel({ + detecting, + onDetect, + status, + upgradeChoice, + detected, + effectiveUpgrade, + onUpgrade, +}: { + detecting: boolean; + onDetect: () => void; + status: DevnetStatus | null; + upgradeChoice: RegtestUpgrade | "auto"; + detected: RegtestUpgrade | null; + effectiveUpgrade: RegtestUpgrade; + onUpgrade: (u: RegtestUpgrade | "auto") => void; +}) { + const [copied, setCopied] = useState(false); + const copyInstall = async () => { + await navigator.clipboard.writeText(INSTALL_CMD); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( +
+

+ Point Cyze at a local{" "} + { + e.preventDefault(); + openUrl("https://github.com/zcashlabs/thus-spoke-zakura").catch(() => {}); + }} + > + thus-spoke-zakura + {" "} + regtest devnet. Addresses start with uregtest1, and + amounts show as rZEC. None of it is real money. +

+ +
+ + {status?.running && ( + + Found · block {status.block_height?.toLocaleString()} + {status.detected_upgrade && <> · {upgradeLabel(status.detected_upgrade)}} · endpoint + filled in, Save to use it + + )} +
+ + {status && !status.running && ( +
+ + {status.detail} + {!status.installed && ( + <> +
+ Install it (Linux / macOS, needs Docker), then run{" "} + thus-spoke-zakura: + + {INSTALL_CMD} + + + + )} +
+
+ )} + {status?.dashboard && ( +

+ Faucet and mining controls:{" "} + { + e.preventDefault(); + openUrl(status.dashboard!).catch(() => {}); + }} + > + {status.dashboard} + +

+ )} + + + +

+ Must match the node: it decides the transaction format and proof circuit. Auto reads it + from the node on Detect or Test connection. +

+ {!devnetCanSend(effectiveUpgrade) && ( +
+ + Sends probably won't work on {upgradeLabel(effectiveUpgrade)}. Cyze + can only build Orchard proofs with the NU6.2+ circuit, and a node on an earlier upgrade + will likely reject them. Syncing, receiving, and balances work normally. To test + sends, run a devnet that activates NU6.2 or NU6.3. + +
+ )} +
+ ); +} + /** In-app diagnostics log: shows what the app has logged this session (sync * timing, errors, ceremony steps) so it can be copied and shared without a * terminal. In-memory only — cleared when the app restarts. */ diff --git a/src/screens/Wallets.tsx b/src/screens/Wallets.tsx index 9df3952..8bc12cf 100644 --- a/src/screens/Wallets.tsx +++ b/src/screens/Wallets.tsx @@ -8,16 +8,15 @@ import { walletGroupStatus, GroupSummary, AppError, + WalletNetwork, } from "../ipc/commands"; +import { asNetwork, unit } from "../network"; import { useCeremonies } from "../stores/ceremonies"; /** Amount display from zatoshis (1 unit = 1e8 zatoshis). */ function zec(zats: number): string { return (zats / 1e8).toLocaleString(undefined, { maximumFractionDigits: 8 }); } -function unit(isMainnet: boolean): string { - return isMainnet ? "ZEC" : "TAZ"; -} /** * The wallet switcher. Exactly one wallet is "active" at a time; the whole app's @@ -33,7 +32,7 @@ export default function Wallets() { const groups = useQuery({ queryKey: ["groups"], queryFn: listGroups }); const active = useQuery({ queryKey: ["active-wallet"], queryFn: getActiveWallet }); const walletConfig = useQuery({ queryKey: ["wallet-config"], queryFn: getWalletConfig }); - const isMainnet = walletConfig.data?.network === "main"; + const network = asNetwork(walletConfig.data?.network); const activeSendByGroup = useCeremonies((s) => s.activeSendByGroup); const activeSigningId = useCeremonies((s) => s.activeSigningId); @@ -94,7 +93,7 @@ export default function Wallets() { select(g)} @@ -121,13 +120,13 @@ export default function Wallets() { function WalletRow({ group, - isMainnet, + network, isActive, pending, onSelect, }: { group: GroupSummary; - isMainnet: boolean; + network: WalletNetwork; isActive: boolean; pending: boolean; onSelect: () => void; @@ -164,7 +163,7 @@ function WalletRow({ {" · "} {!s || !s.initialized ? "not set up yet" - : `${zec(total)} ${unit(isMainnet)}`} + : `${zec(total)} ${unit(network)}`}