diff --git a/TODO.md b/TODO.md
index 15c6e0c..7959ebe 100644
--- a/TODO.md
+++ b/TODO.md
@@ -53,40 +53,36 @@ current build depends on them.
identity. An identicon derived *from* the pubkey is the one variant that
strengthens rather than weakens this, which argues for it.
-- [ ] **Tailscale `serve` as a fourth hosting option** — alongside Direct URL,
- Cloudflare Tunnel, and NGINX in Session Configuration.
-
- **Feasibility/impact (2026-08-01):** effort Med, impact Med–High, not gated
- by the testnet-send validation. Slots in as a fourth `coordinator_exposure`
- variant reusing the existing exposure plumbing + a status probe; no crypto
- change (frostd's Noise layer still authenticates end-to-end). Structural
- difference from cloudflared: **detect-and-drive a system `tailscale` CLI,
- do NOT bundle** — it needs the `tailscaled` daemon (privileged) and a
- logged-in tailnet, so the sidecar-spawn pattern doesn't apply. Read the
- stable MagicDNS hostname back via `tailscale status --json` as the saved
- server URL. Verdict: **do** — best fix for the disposable-quick-tunnel URL
- pain (stable, savable, auto-TLS, tailnet-scoped).
-
- Why it is attractive: `tailscale serve https / http://127.0.0.1:`
- exposes the loopback frostd over the tailnet with a **stable** MagicDNS
- hostname and an automatically-provisioned, publicly-valid TLS certificate.
- That fixes the two things that hurt most about quick tunnels: the URL is
- **not disposable** (so it can be saved as a group's server and reused), and
- there is no cert-trust step. Access is also restricted to the tailnet rather
- than the whole internet, which is a strictly better default for a signing
- server. (`tailscale funnel` would expose it publicly if a participant is
- outside the tailnet.)
-
- Open questions:
- - Detect an existing `tailscale` binary/daemon, or bundle it? Bundling is
- heavier than `cloudflared` and the daemon needs privileges — detection
- plus a clear "install Tailscale" path is likely the right first cut.
- - Every participant must be on the tailnet (or the coordinator uses Funnel).
- That is a real constraint to surface in the UI, not bury.
- - Reuse the existing exposure plumbing: this is a new `Exposure` variant
- plus a status probe; the trust model is unchanged (frostd's Noise layer
- still authenticates end-to-end, so the transport only provides
- reachability).
+- [x] **Tailscale `serve` as a fourth hosting option** — DONE
+ (`feat/tailscale-serve`). A fourth `coordinator_exposure` variant
+ (`"tailscale"`) in Session Configuration, alongside Direct URL, Cloudflare
+ Tunnel, and NGINX.
+
+ Implementation: `src-tauri/src/tailscale.rs` **detects and drives a system
+ `tailscale` CLI** (not bundled — needs the privileged `tailscaled` daemon +
+ a logged-in tailnet). `tailscale serve --bg --https=443
+ https+insecure://127.0.0.1:` puts the loopback frostd behind the
+ machine's stable MagicDNS name on the tailnet, with auto-provisioned public
+ TLS (so participants connect with system roots — no cert-trust step). We use
+ `serve` (tailnet-only), never `funnel` (public). The MagicDNS name is read
+ from `tailscale status --json` (`Self.DNSName`), and `available`/`detail`
+ surface *why* it's not ready (not installed / signed out / offline).
+ Commands `start_tailscale_serve` / `stop_tailscale_serve` /
+ `tailscale_status`; `AppState.tailscale` handle; serve is torn down when the
+ sidecar stops and on app exit (`stop_serve_blocking`, since the mapping
+ lives in the daemon). UI: a Tailscale tab + `TailscaleExposure` in
+ `SessionSetup.tsx`; the stable `.ts.net` URL is (correctly) *not* treated as
+ ephemeral, so it saves + reuses like a normal server. `https+insecure` is the
+ tailnet equivalent of cloudflared's `--no-tls-verify`; frostd's Noise layer
+ still authenticates end-to-end. Compile-verified + unit tests; **needs a
+ live run** on a machine with Tailscale installed/signed-in to confirm the
+ `serve` invocation against the current CLI.
+
+ Possible follow-ups: bundle/guide an install path if detection proves too
+ bare; surface the "all participants must be on the tailnet" constraint even
+ more prominently; optional `funnel` toggle for a participant outside the
+ tailnet (explicitly public — would need the same loud warning as the
+ Cloudflare tunnel).
## Voting (coinholder polling)
diff --git a/docs/UAT.md b/docs/UAT.md
new file mode 100644
index 0000000..2e1b4a2
--- /dev/null
+++ b/docs/UAT.md
@@ -0,0 +1,234 @@
+# UAT — pipelined sync & Tailscale serve
+
+User-acceptance checklists for the two in-flight features. Each part names the
+branch it needs; until both merge to `main`, test each on its own branch build
+(they don't depend on each other).
+
+- **Part A — Pipelined sync** → branch `feat/sync-optimizations`
+- **Part B — Tailscale serve** → branch `feat/tailscale-serve`
+
+Build fresh and launch the built binary each time (`npm run tauri build`, or
+`cargo build` + `npm run tauri dev`) — never a previously installed bundle.
+
+---
+
+# Part A — Experimental pipelined sync (`feat/sync-optimizations`)
+
+Goal: prove the pipelined driver produces the **same wallet state** as the stock
+driver, only faster. Testnet first. Off by default; opt in via `settings.json`
+(`/settings.json`) → `"experimental_pipelined_sync": true|false`.
+Toggling takes effect on the next sync (Sync Now / relaunch). See
+`docs/SYNC_OPTIMIZATION.md` for the design.
+
+## A0. Setup
+- [ ] Fresh build launched (not an installed bundle).
+- [ ] A **testnet** group with real history (funded a few times, ≥1 send).
+- [ ] Know how to edit `experimental_pipelined_sync` in `settings.json`.
+
+## A1. Baseline — stock driver (control)
+- [ ] `experimental_pipelined_sync` is `false`/absent.
+- [ ] Delete the group's wallet db (force full rescan) and sync to tip; note the
+ rough wall-clock time.
+- [ ] Record: total balance + Orchard/Ironwood split; received-note count;
+ transaction history (count + amounts); scanned-to height (= chain tip).
+
+## A2. Pipelined — clean-state equality (the core test)
+- [ ] Set `experimental_pipelined_sync` to `true`.
+- [ ] Delete the wallet db again (same start as A1) and sync to tip.
+- [ ] Log shows **"using experimental pipelined sync driver"** (not a fallback).
+- [ ] Balance **byte-identical to A1** — total, Orchard, and Ironwood all match.
+- [ ] Received-note count matches A1.
+- [ ] Transaction history matches A1 (txids, amounts, memos).
+- [ ] Scanned-to height reaches the chain tip.
+- [ ] Wall-clock sync time is **≤ A1** (bigger win on a high-latency link).
+
+## A3. Incremental sync
+- [ ] From tip, receive a new testnet payment, then Sync Now → only new blocks
+ scanned, new note appears, balance rises by the expected amount.
+- [ ] Sync again with no activity → quick, balance unchanged (no drift/double-count).
+
+## A4. Cancellation / resume
+- [ ] Start a full rescan (delete db), then cancel mid-sync (Sync Now / navigate away).
+- [ ] App stays responsive; no panic; at most an expected "cancelled".
+- [ ] Sync again → resumes and completes at the same balance/height as A2.
+
+## A5. Reorg tolerance (best-effort)
+- [ ] If a reorg occurs during a sync, log shows "chain reorg detected … rewinding"
+ and the sync still finishes at the correct tip/balance. (Opportunistic.)
+
+## A6. Send after a pipelined sync (funds path)
+- [ ] After a pipelined sync, build + FROST-sign + broadcast a small testnet send.
+- [ ] Node accepts it (no branch-id / MissingSpendAuthSig / selection errors).
+- [ ] After confirmation, a re-sync shows the spend and reduced balance.
+
+## A7. Regression — flag off still works
+- [ ] Set the flag back to `false`, sync once → stock path works normally.
+
+## A — Sign-off
+- [ ] A1 vs A2 identical across balance/notes/history/height.
+- [ ] A3, A4, A6 pass on testnet. No panics, no stuck syncs, UI responsive.
+- [ ] Only then: consider flipping the default, and repeat A1/A2/A6 once on
+ **mainnet** with a small balance before recommending broadly.
+
+---
+
+# Part B — Tailscale serve hosting (`feat/tailscale-serve`)
+
+Goal: a coordinator can publish the embedded frostd to their tailnet at a stable
+`*.ts.net` URL, participants on the same tailnet connect with no cert-trust step,
+and the mapping is cleaned up correctly. `serve` is tailnet-only (not public).
+
+## B0. Setup
+- [ ] Fresh build launched on the **coordinator** machine.
+- [ ] Tailscale installed and **signed in** on the coordinator (`tailscale status`
+ shows Running + online).
+- [ ] A **second device on the same tailnet** to act as a participant (another
+ Cyze install, or at least a browser/curl to hit the URL).
+- [ ] (For B6) a device **not** on the tailnet, to confirm scoping.
+
+## B1. Detection states (before serving)
+The Tailscale panel now appears in **two** places (same component, same
+behavior): **Zcash → Session Configuration → Coordinator → Tailscale** tab, and
+**1 · Setup → Server → Host a server here → Tailnet access (Tailscale)**. Run B1
+on the Session Configuration tab; B1c re-checks the Server-screen copy.
+
+Verify the guidance matches reality:
+- [ ] **Signed in & online** → tab shows this machine's `https://.ts.net`
+ and a **"Publish to tailnet"** button.
+- [ ] **Signed out** (`tailscale logout`) → shows a "not connected" message and a
+ **"Sign in to Tailscale"** button (not the Publish button).
+- [ ] **Tailscale stopped** (`tailscale down`) → shows a "not connected" message
+ with the **Sign in** button.
+- [ ] **Not installed** (test machine without Tailscale, or rename the binary) →
+ shows "not installed" and a **"Get Tailscale"** button.
+
+## B1a. Get Tailscale (not-installed friction)
+- [ ] On a machine without Tailscale, click **Get Tailscale** → the system default
+ browser opens `https://tailscale.com/download` (not an in-app webview).
+- [ ] The download URL is **also shown as copyable text** beside the button
+ (`Copy link` works), so it's reachable even if the browser didn't open.
+- [ ] **Fallback path:** if the browser does **not** open automatically, the line
+ reads "Couldn't open your browser automatically…" — not silent (the earlier
+ bug was that clicking did nothing). Copy the link and it opens Tailscale's
+ download page.
+- [ ] Install Tailscale, then reopen the tab → it now shows the **Sign in** state
+ (installed, not yet connected).
+
+## B1b. Sign in to Tailscale (signed-out friction)
+- [ ] With Tailscale installed but signed out, click **Sign in to Tailscale**.
+- [ ] Either a browser opens to `login.tailscale.com` automatically, **or** an
+ "open the sign-in page" link appears — clicking it opens the login URL.
+- [ ] Complete auth in the browser; within a few seconds the tab **auto-updates**
+ to the signed-in state (shows the `.ts.net` name + Publish button) with no
+ manual refresh.
+- [ ] If the browser can't be opened automatically, a **"Couldn't open your
+ browser automatically. Copy this link:"** line with the login URL + a
+ `Copy link` button appears (no silent no-op).
+- [ ] (Linux note) If `tailscale up` needs elevated rights on this host, the tab
+ surfaces that instead of hanging — the operator/sudo message is shown.
+
+## B1c. Second entry point — Server screen
+- [ ] Go to **1 · Setup → Server**, expand **Host a server here**. Under the
+ Cloudflare tunnel section there is a **"Tailnet access (Tailscale)"**
+ sub-section.
+- [ ] It shows the **same** state as the Session Configuration tab did in B1
+ (not-installed / signed-out / ready-to-publish), driven by the same status.
+- [ ] With the embedded server **not** started, it prompts to start the server
+ first; with it started, the Publish/Sign-in/Get-Tailscale action matches B1.
+- [ ] Publishing from **either** screen and stopping from the other stays
+ consistent (one shared serve mapping, not two).
+
+## B2. Happy path — publish
+- [ ] Start the embedded server (Step 1).
+- [ ] Tailscale tab → **Publish to tailnet** → badge **"serving on tailnet"** and
+ a stable `https://.ts.net` URL (no port).
+- [ ] `tailscale serve status` on the coordinator shows the 443 → 127.0.0.1:
+ mapping (confirms the CLI invocation succeeded — the one flagged risk).
+- [ ] Copy URL works.
+
+## B3. Reachability from a tailnet participant
+- [ ] On the participant device, open the URL / paste it into Participant setup and
+ **Test connection** → succeeds, `tls` reported as **public** (no cert import),
+ reasonable latency.
+- [ ] No certificate-trust step was needed anywhere.
+
+## B4. Stable save & reuse
+- [ ] Save the `.ts.net` URL as the server (it is **not** treated as ephemeral).
+- [ ] Fully quit and relaunch Cyze; re-publish; the URL is the **same** as before.
+- [ ] The saved server still connects after relaunch (contrast: a Cloudflare quick
+ tunnel would have a new URL).
+
+## B5. Teardown paths
+- [ ] **Stop serving** button → badge clears; from the participant the URL no
+ longer reaches frostd; `tailscale serve status` shows the mapping gone.
+- [ ] Re-publish, then **Stop server** (Step 1) → serve mapping is also torn down
+ (sidecar stop cascades to Tailscale).
+- [ ] Re-publish, then **quit the app** → after quit, `tailscale serve status`
+ shows no leftover 443 mapping (exit cleanup ran).
+
+## B6. Tailnet scoping (not public)
+- [ ] From a device **not** on the tailnet, the `.ts.net` URL does **not** resolve/
+ connect (confirms `serve`, not `funnel` — access is tailnet-scoped).
+
+## B7. End-to-end ceremony over Tailscale
+- [ ] With serve up and a participant joined via the `.ts.net` URL, run a real
+ **signing** (or DKG) ceremony to completion over the tailnet transport.
+
+## B8. Participant "I'm joining" URL list (formatting)
+- [ ] As a **participant**: Zcash → Session Configuration → **I'm joining** →
+ "Connect to the coordinator's server".
+- [ ] The four example addresses (domain, direct IP, Cloudflare, Tailscale) render
+ as a **clean two-column list** — example URLs in the left column, their
+ descriptions aligned in the right — not a run-on line with `•`/stray spacing
+ (the earlier messy layout).
+- [ ] The block reads correctly at a narrow window width (no horizontal overflow,
+ descriptions stay aligned).
+
+## B — Sign-off
+- [ ] B1a/B1b: **Get Tailscale** opens the download page and **Sign in** drives
+ `tailscale up` to a connected state, with the tab auto-updating; the
+ copyable-link fallback shows when the browser can't be opened.
+- [ ] B1c: the Server-screen Tailscale sub-section mirrors the Session
+ Configuration tab and shares one serve mapping.
+- [ ] B2–B5 pass; the URL is stable across relaunch and cleaned up on stop/quit.
+- [ ] B6 confirms tailnet-only scoping.
+- [ ] B7 completes a real ceremony over the transport.
+- [ ] B8: the participant join-URL list is cleanly aligned.
+- [ ] Note the Tailscale CLI version tested here: ____________ (so we know which
+ `serve` grammar was validated).
+
+---
+
+# Part C — In-app log viewer (on `main`; present on both branches)
+
+Goal: the app captures its own `tracing` output to an in-memory buffer and shows
+it in the UI, so a tester can copy logs and share them back without hunting for a
+terminal. Bounded (~3000 lines), in-memory only, cleared on restart. Lives at
+**Zcash → Wallet Settings**, the **"Diagnostics log"** card near the bottom.
+
+## C1. Card shows live output
+- [ ] Open **Zcash → Wallet Settings** and find the **Diagnostics log** card.
+- [ ] It already contains startup lines (the buffer captures from app start, so
+ it is not empty on first open). The header shows an **"N lines · this
+ session"** count.
+- [ ] The **Live** toggle is on by default: do something that logs — e.g. **Sync
+ Now** on the active wallet — and within a couple of seconds new lines appear
+ **without** clicking anything. **Refresh** forces an immediate update.
+- [ ] Lines are oldest-first; the view stays pinned to the newest line while Live,
+ unless you scroll up to read older output.
+- [ ] Un-checking **Live** stops the auto-updates (count holds until Refresh).
+
+## C2. Copy & clear
+- [ ] **Copy all** → button flips to "Copied!"; paste into a scratch file and
+ confirm it matches the text shown (Copy all is disabled when empty).
+- [ ] **Clear** empties the buffer (button disabled when already empty);
+ subsequent activity repopulates it.
+
+## C3. Persistence boundary
+- [ ] Fully quit and relaunch Cyze → the card starts fresh (in-memory only, not
+ persisted across runs). Only new-session lines are present.
+
+## C — Sign-off
+- [ ] Card is populated from app start, auto-updates on activity while Live, and
+ Copy all / Refresh / Clear work. Buffer resets on relaunch. Spot-check the
+ captured lines expose nothing sensitive (no passphrases / key material).
diff --git a/src-tauri/src/commands/server.rs b/src-tauri/src/commands/server.rs
index 17a7709..88a6cc4 100644
--- a/src-tauri/src/commands/server.rs
+++ b/src-tauri/src/commands/server.rs
@@ -5,6 +5,7 @@ use tauri::{AppHandle, Manager, State};
use crate::error::AppResult;
use crate::sidecar::{self, SidecarStatus};
use crate::state::{AppState, Settings};
+use crate::tailscale::{self, TailscaleStatus};
use crate::tunnel::{self, TunnelStatus};
#[tauri::command]
@@ -200,9 +201,10 @@ pub async fn start_sidecar(
#[tauri::command]
pub async fn stop_sidecar(state: State<'_, AppState>) -> AppResult<()> {
- // The tunnel points at the embedded server; stopping the server makes it
- // dead weight, so tear it down too.
+ // The tunnel and the Tailscale serve mapping both point at the embedded
+ // server; stopping the server makes them dead weight, so tear them down too.
let _ = tunnel::stop(&state).await;
+ let _ = tailscale::stop(&state).await;
sidecar::stop(&state).await
}
@@ -236,6 +238,79 @@ pub async fn tunnel_status(state: State<'_, AppState>) -> AppResult` URL participants on the tailnet can use. Requires
+/// a system Tailscale that is installed, signed in, and online.
+#[tauri::command]
+pub async fn start_tailscale_serve(state: State<'_, AppState>) -> AppResult {
+ let port = {
+ let guard = state.sidecar.lock().await;
+ match guard.as_ref() {
+ Some(handle) => handle.port,
+ None => {
+ return Err(crate::error::AppError::new(
+ "tailscale",
+ "start the embedded server before starting Tailscale serve",
+ ))
+ }
+ }
+ };
+ tailscale::start(&state, port).await
+}
+
+#[tauri::command]
+pub async fn stop_tailscale_serve(state: State<'_, AppState>) -> AppResult<()> {
+ tailscale::stop(&state).await
+}
+
+#[tauri::command]
+pub async fn tailscale_status(state: State<'_, AppState>) -> AppResult {
+ Ok(tailscale::status(&state).await)
+}
+
+/// Trigger `tailscale up`; returns a login URL to open when authentication is
+/// needed (the poll then flips to available once sign-in completes).
+#[tauri::command]
+pub async fn tailscale_sign_in() -> AppResult {
+ tailscale::sign_in().await
+}
+
+/// Open a URL in the user's default browser (e.g. the Tailscale download or the
+/// sign-in link) via the OS default handler. Restricted to http(s) so it can
+/// only ever launch a browser, never an arbitrary program or file.
+#[tauri::command]
+pub async fn open_url(url: String) -> AppResult<()> {
+ if !(url.starts_with("https://") || url.starts_with("http://")) {
+ return Err(crate::error::AppError::new(
+ "open",
+ "refusing to open a non-http(s) URL",
+ ));
+ }
+ #[cfg(target_os = "linux")]
+ let mut command = {
+ let mut c = tokio::process::Command::new("xdg-open");
+ c.arg(&url);
+ c
+ };
+ #[cfg(target_os = "macos")]
+ let mut command = {
+ let mut c = tokio::process::Command::new("open");
+ c.arg(&url);
+ c
+ };
+ #[cfg(target_os = "windows")]
+ let mut command = {
+ // `start` is a cmd builtin; the empty "" is its window-title argument.
+ let mut c = tokio::process::Command::new("cmd");
+ c.args(["/C", "start", "", &url]);
+ c
+ };
+ command
+ .spawn()
+ .map_err(|e| crate::error::AppError::new("open", format!("opening browser: {e}")))?;
+ Ok(())
+}
+
/// The in-memory application log (oldest line first), for display + copy in the
/// app. Captured from `tracing` since app start; bounded and not persisted.
#[tauri::command]
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index f84ddbb..c608103 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -3,6 +3,7 @@ pub mod error;
pub mod logbuf;
pub mod sidecar;
pub mod state;
+pub mod tailscale;
pub mod tunnel;
use state::AppState;
@@ -111,6 +112,11 @@ pub fn run() {
commands::server::start_tunnel,
commands::server::stop_tunnel,
commands::server::tunnel_status,
+ commands::server::start_tailscale_serve,
+ commands::server::stop_tailscale_serve,
+ commands::server::tailscale_status,
+ commands::server::tailscale_sign_in,
+ commands::server::open_url,
commands::server::get_logs,
commands::server::clear_logs,
commands::dkg::start_dkg,
@@ -136,6 +142,15 @@ pub fn run() {
let _ = handle.child.kill();
}
}
+ // Tailscale `serve` lives in the tailscaled daemon, not a
+ // child process, so turn off the mapping synchronously if we
+ // set one — otherwise it outlives the app pointing at a dead
+ // port.
+ if let Ok(mut guard) = state.tailscale.try_lock() {
+ if guard.take().is_some() {
+ crate::tailscale::stop_serve_blocking();
+ }
+ }
}
}
});
diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs
index 7f8cc3b..43e9ddc 100644
--- a/src-tauri/src/state.rs
+++ b/src-tauri/src/state.rs
@@ -126,6 +126,9 @@ pub struct AppState {
pub sidecar: Mutex
- Paste the address the coordinator is sharing right now. It looks like one
- of:
-
- • https://frost.example.com{" "}
- — a domain / NGINX server
-
- • https://203.0.113.7:2744{" "}
- — a direct IP and port
-
- • https://long-random-words.trycloudflare.com{" "}
- — a Cloudflare tunnel
-
- A Cloudflare tunnel URL is disposable: the coordinator
- gets a new one each time they restart it, so always use the latest.
+
+
+ Paste the address the coordinator is sharing right now. It looks like
+ one of:
+
+
+ https://frost.example.com
+ a domain / NGINX server
+ https://203.0.113.7:2744
+ a direct IP and port
+ https://long-random-words.trycloudflare.com
+ a Cloudflare tunnel
+ https://their-machine.tailnet.ts.net
+ a Tailscale address (you must be on the same tailnet)
+
+
+ A Cloudflare tunnel URL is disposable: the coordinator
+ gets a new one each time they restart it, so always use the latest. A
+ Tailscale .ts.net address is{" "}
+ stable — save it once and reuse it.
+
@@ -609,8 +629,9 @@ function ParticipantPath() {
Self-signed server? Trust its certificate
- Only needed for a Direct-URL coordinator (not for a Cloudflare tunnel or
- an NGINX/domain server, which use publicly trusted TLS). Paste the
+ Only needed for a Direct-URL coordinator (not for a Cloudflare tunnel, a
+ Tailscale address, or an NGINX/domain server, which use publicly trusted
+ TLS). Paste the
certificate PEM the coordinator shared and confirm the fingerprint with
them out-of-band before trusting it.