diff --git a/CHANGELOG.md b/CHANGELOG.md index d0e9cba..07800f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to Cyze are recorded here. Versions follow [semantic versioning](https://semver.org); pre-1.0 minor bumps may include breaking changes. Cyze is beta, unaudited software (see the README). +## [Unreleased] + +### Added +- **Local devnet (regtest) network.** Point the wallet at a local + [thus-spoke-zakura](https://github.com/zcashlabs/thus-spoke-zakura) regtest + devnet for Zcash development. **Detect local devnet** finds its randomly + assigned lightwalletd port; the chain's network upgrade is read from the node + automatically (or forced in settings). Devnet wallets use `uregtest1…` + addresses, an `rZEC` ticker, their own wallet data, and scan the whole devnet + chain so faucet funds show up right away. On a devnet below NU6.2 (the stock + launcher runs NU6), syncing and receiving work but sends will likely be + rejected: Cyze can only build Orchard proofs with the NU6.2+ circuit. + +### Fixed +- The approver's Inbox showed amounts as "ZEC" on every network; it now uses the + request's ticker (ZEC / TAZ / rZEC). +- The group page encoded addresses for testnet when no network had been saved, + while the wallet used mainnet; both now resolve the network the same way. + ## [0.2.0-rc.1] — 2026-08-16 First release candidate for the **Ironwood (NU6.3)** feature wave. Still targets diff --git a/README.md b/README.md index 5b53e51..b0e2c94 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ authorize any spend. active wallet at a time** — selecting a group makes it the sole wallet the app syncs and acts on. Includes on-chain and local transaction/message history, and an in-app diagnostics log for troubleshooting. +- **Local devnet for development** — besides mainnet and testnet, the wallet can + run against a local regtest devnet + ([thus-spoke-zakura](https://github.com/zcashlabs/thus-spoke-zakura)), found + with one click, for Zcash protocol and app development with throwaway funds. - **Coinholder voting** — cast a Zcash coinholder-poll vote from a group: paste the poll's published ballot, answer, and the vote is delivered as a shielded memo (Vote Cast Memo v1) to the poll's reception address through the same FROST diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 7c1ccfc..a86d185 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -251,10 +251,11 @@ balance and history update (use **Sync Now** on the Wallet page if needed). Network and light-client endpoint are set on the **Wallet Settings** page. **Network.** Cyze defaults to **Mainnet**. The active network is shown as a small -**Mainnet / Testnet** label on the wallet page, and a real mainnet send asks for -one confirmation before it broadcasts. Switch to **Testnet** to test with faucet -funds, and back to Mainnet when ready. Balances, addresses, and history are kept -entirely separate per network. +**Mainnet / Testnet / Devnet** label on the wallet page, and a real mainnet send +asks for one confirmation before it broadcasts. Switch to **Testnet** to test with +faucet funds, and back to Mainnet when ready. **Local devnet** is for development +(see below). Balances, addresses, and history are kept entirely separate per +network. **lightwalletd endpoint.** Each network offers a **preset** public endpoint (`zec.rocks` on mainnet, `testnet.zec.rocks` on testnet). To use your own node, @@ -271,6 +272,49 @@ time**. Use **Zcash → Wallets** to switch between groups; selecting one makes the active wallet, and the app stops syncing the previous one so all processing stays focused on your choice. +### Local devnet (regtest) + +For Zcash development, Cyze can run against a private regtest chain on your own +machine, using +[thus-spoke-zakura](https://github.com/zcashlabs/thus-spoke-zakura). Nothing on +a devnet is real money: addresses start with `uregtest1…` and amounts show as +**rZEC**. + +1. **Install and start the devnet** (Linux or macOS, needs Docker): + ```sh + curl --proto '=https' --tlsv1.2 -fsSL https://raw.githubusercontent.com/zcashlabs/thus-spoke-zakura/main/install.sh | sh + thus-spoke-zakura # keep it running; Ctrl-C deletes the devnet + ``` +2. In **Wallet Settings**, choose **Local devnet** and click **Detect local + devnet**. Cyze asks the `thus-spoke-zakura` CLI where lightwalletd is + listening (it picks a random port) and checks that it answers. Then click + **Save**. If Cyze can't find the CLI, run `thus-spoke-zakura endpoints` and + paste the `lightwalletd` URL (`http://127.0.0.1:`) into the endpoint + field. Plain `http://` is allowed only for local addresses. On Windows, run + the devnet in WSL2 and enter the URL by hand. +3. **Network upgrade.** A devnet picks its own upgrades, and the wallet must + match the node. **Auto** reads the upgrade from the node when you click + Detect or Test connection. You can also force NU6 through NU6.3 (Ironwood). +4. **Fund the group.** Open the group's wallet, copy its `uregtest1…` address, + and use the faucet on the devnet dashboard (the link appears after Detect). + Mine a few blocks there so the funds confirm. Devnet wallets scan the whole + chain, so funds sent before you opened the wallet still show up. + +**Limitations:** +- **Sends need NU6.2 or later.** The stock devnet runs **NU6**. Cyze can only + build Orchard proofs with the NU6.2+ circuit, so a NU6 node will most likely + reject a send, and the settings page warns you about this. Syncing, receiving, + and balances work. To test sends, use a devnet that activates NU6.2 or NU6.3. +- **Before Ironwood, the balance is the Orchard pool.** Until the devnet + activates NU6.3, the balance shows the Orchard pool, because that's where + devnet funds land. +- ZcashNames don't resolve on a devnet. +- **Resetting the devnet starts a brand-new chain.** A group's devnet wallet + still holds the old chain, so after `thus-spoke-zakura reset` (or recreating + it), delete that wallet's data: the `wallets//regtest/` folder in + Cyze's data directory. It is rebuilt on the next sync. This only affects + devnet data. Testnet and mainnet wallets are stored separately. + **Diagnostics log.** Wallet Settings has a **Diagnostics log** card that captures what the app logs while running (sync steps, errors). Use **Copy all** to grab it for troubleshooting; it's in memory only and clears on restart. 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..eb28a09 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", } } } @@ -149,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) @@ -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) 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, 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)}`}