diff --git a/README.md b/README.md index 760531b..6ce42a6 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,13 @@ authorize any spend. > ### ⚠️ Beta software — unaudited — use at your own risk > -> Cyze is in **beta** and has **not been security-audited**. It depends on -> pre-release Zcash libraries (Orchard/PCZT for the Ironwood network upgrade). -> Do not use it to hold funds you cannot afford to lose. You are solely -> responsible for backing up your key shares and recovery code, and for any -> transaction you broadcast. **No warranty is provided.** Start on testnet, and -> if you use mainnet, use small amounts. +> Cyze is in **beta** and has **not been security-audited**. It targets the +> **Ironwood (NU6.3)** network upgrade and depends on some release-candidate +> Zcash libraries (`zcash_client_backend`/`zcash_client_sqlite`). Do not use it +> to hold funds you cannot afford to lose. You are solely responsible for backing +> up your key shares and recovery code, and for any transaction you broadcast. +> **No warranty is provided.** Start on testnet, and if you use mainnet, use +> small amounts. --- @@ -28,10 +29,23 @@ authorize any spend. - **Threshold signing** — coordinate a signing session (the coordinator can also be a signer), or participate through an inbox with an explicit review/approve step before your signature share is produced. -- **Zcash wallet** — for RedPallas (Orchard) groups: sync from a lightwalletd - server, view shielded balances, receive to a group address (with QR), and - **send** — each spend is authorized by a live FROST signing ceremony among the - group. Includes on-chain and local transaction/message history. +- **Zcash wallet (Ironwood-ready)** — for RedPallas (Orchard) groups: sync from a + lightwalletd server, view **per-pool shielded balances** — the sealed legacy + **Orchard** pool and the post-NU6.3 **Ironwood** pool — receive to a rotating + group address (with QR), and **send** — each spend is authorized by a live + FROST signing ceremony among the group. After the **Ironwood (NU6.3)** upgrade, + sends build **V6 transactions** with pool-aware Orchard/Ironwood spend + authorization, and a one-click **Orchard → Ironwood migration** sweeps the + sealed legacy pool across the turnstile. Includes on-chain and local + transaction/message history. +- **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 + signing path. Vote weight is set by the poll's balance snapshot, not the wallet. +- **ZcashNames (ZNS)** — send to a human-readable `name.zcash` in the recipient + field: it is resolved to a unified address via the public ZNS indexer and shown + for you to confirm before signing (the resolver is external, never an + authorization). - **Server hosting** — run the `frostd` coordination server embedded (auto-generated, pinned self-signed TLS), expose it to off-LAN peers through a built-in **Cloudflare tunnel** (public HTTPS URL, no port-forwarding), or point @@ -134,8 +148,9 @@ drives the full Tauri command layer (`cargo test -p frost-app --test smoke`). ## Layout - `src-tauri/core` — `frost-app-core`: keystore, frostd transport (pinned-cert - TLS), DKG/signing ceremony engines, and the Zcash wallet/PCZT send path. No - Tauri dependency. + TLS), DKG/signing ceremony engines, the Zcash wallet/PCZT send path (Orchard + + Ironwood), coinholder-poll voting (`voting.rs`), and ZcashNames resolution + (`zns.rs`). No Tauri dependency. - `src-tauri/src` — Tauri adapter: commands, event forwarding, sidecar lifecycle. - `src/` — React + TypeScript frontend. - `scripts/PINNED_REV` — the frost-tools revision used for both the diff --git a/TODO.md b/TODO.md index ca069f7..15c6e0c 100644 --- a/TODO.md +++ b/TODO.md @@ -9,30 +9,28 @@ current build depends on them. groups/wallets (e.g. `treasury.zcash` → the group's shielded receive address). Repo: https://github.com/zcashme/zcashnames - How ZNS works: names are claimed/updated **on-chain via ZIP-321 payment - URIs + signed memos**, and a separate **ZNS Indexer / Directory** service - (SQL-backed, with a web app) indexes them for resolution — so a wallet - resolves a name by querying that directory, not by scanning the chain - itself. Two directions, very different effort: - - - **Resolve (read) — low effort, high value.** Accept a `name.zcash` in the - Send recipient field and in contact entries; resolve it to an address via - the ZNS directory before building the tx. Pairs naturally with the - avatars/account-profile identity work. Gated on the directory exposing a - public resolver API (likely HTTP; no confirmed Rust crate yet — verify). - **Trust caveat:** the resolver is external infrastructure, so ALWAYS show - the resolved shielded address for confirmation before a send — a wrong or - compromised resolver could otherwise redirect funds. Never send to a name - without surfacing what it resolved to. - - - **Register/claim (write) — higher effort.** Let a group publish a name for - its receive address. This is a threshold-authorised on-chain action, so it - fits Cyze's existing FROST send + memo path — but confirm the exact - claim/update memo + ZIP-321 format against the ZNS spec first, and decide - who in the group is allowed to initiate a (re)claim. - - Do the resolver first; it's the piece that makes long unified addresses - usable and is independent of the write path. + How ZNS works: names are claimed/updated **on-chain via Ed25519-signed + memos** (`ZNS:CLAIM:::[:]` in an Orchard note), and a + **ZNS indexer** scans the chain and exposes a JSON-RPC API for resolution — + so a wallet resolves by querying the indexer, not by scanning itself. + + - **Resolve (read) — DONE (this branch).** `name.zcash` in the Send recipient + field resolves via the public indexer's JSON-RPC `resolve` method + (`core/src/zns.rs` + `resolve_zns_name` command; endpoints + `https://light.zcash.me/zns-{testnet,mainnet-test}`). The resolved address + is shown for the user to confirm before sending, with a warning when the + name is listed for sale. Remaining read follow-ups: accept names in + **contact entries** too, and add a small resolver **cache**. + + - **Register/claim (write) — TODO, higher effort.** Let a group publish a + name for its receive address. Threshold-authorised on-chain action that + fits Cyze's FROST send + memo path: build the `ZNS:CLAIM:…` memo with an + Ed25519 signature over the claim, sign the transaction with the group, and + send it to the indexer's admin/registration address. Open questions: where + the claim's Ed25519 key lives (per-group, in the keystore?), the exact + pricing/fee tiers (indexer `status` method), and who may initiate a + (re)claim. Note ownership is **sovereign** — once claimed with a key, all + later actions on the name must be signed by that same key. - [ ] **User avatars** — let a user pick an avatar, shown next to their name everywhere they are referenced (contacts, group participant lists, the diff --git a/src-tauri/core/src/lib.rs b/src-tauri/core/src/lib.rs index c014fb0..9cd7da8 100644 --- a/src-tauri/core/src/lib.rs +++ b/src-tauri/core/src/lib.rs @@ -16,6 +16,7 @@ pub mod signing; pub mod tls; pub mod transport; pub mod voting; +pub mod zns; #[cfg(feature = "zcash")] pub mod zcash; #[cfg(feature = "wallet")] diff --git a/src-tauri/core/src/zns.rs b/src-tauri/core/src/zns.rs new file mode 100644 index 0000000..95b974d --- /dev/null +++ b/src-tauri/core/src/zns.rs @@ -0,0 +1,241 @@ +//! ZcashNames (ZNS) resolution: turning a human-readable name like `alice` +//! (or `alice.zcash`) into the Zcash unified address it points at, so a user can +//! send to a name instead of a long address. +//! +//! # Protocol (as of 2026-08) +//! +//! Names are claimed on-chain as Ed25519-signed memos inside Orchard notes — +//! colon-delimited UTF-8, e.g. `ZNS:CLAIM:::[:]` where the +//! signature is base64 Ed25519 over everything between `ZNS:` and `:`. Names +//! match `[a-z0-9]{1,62}`. A **ZNS indexer** scans the chain, verifies every +//! signature, and exposes a JSON-RPC API; resolution is a query against it, not a +//! chain scan by this wallet. +//! +//! Public indexer endpoints (zcash.me): +//! - testnet: `https://light.zcash.me/zns-testnet` +//! - mainnet: `https://light.zcash.me/zns-mainnet-test` +//! +//! The `resolve` method takes `params: [query, limit?, offset?]`. For a **name** +//! query it returns a single [`ResolveResult`] (or `null` if unregistered); the +//! `address` field is the unified address to pay. (An address query returns an +//! array — one address may own several names; an empty query lists all.) +//! +//! # Trust +//! +//! The indexer is external infrastructure. A wrong or compromised resolver could +//! return an attacker's address, so callers MUST show the resolved address for the +//! user to confirm before sending — a name is a convenience, never an +//! authorization. This module only *reads*; claiming/registering a name (the +//! signed-memo write path) is a separate, larger piece (see TODO.md). + +use serde::{Deserialize, Serialize}; + +use crate::error::CoreError; + +/// The public ZNS indexer JSON-RPC endpoint for a network. +pub fn endpoint(mainnet: bool) -> &'static str { + if mainnet { + "https://light.zcash.me/zns-mainnet-test" + } else { + "https://light.zcash.me/zns-testnet" + } +} + +/// A resolved ZNS registration. Extra fields returned by the indexer (txid, +/// height, nonce, signature, pubkey, …) are ignored — the wallet needs the name, +/// the address to pay, and enough to warn the user. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ResolveResult { + /// The resolved name (without the `.zcash` suffix). + pub name: String, + /// The Zcash unified address the name points at — the send recipient. + pub address: String, + /// The most recent on-chain action for the name (e.g. `CLAIM`, `UPDATE`). + #[serde(default)] + pub last_action: Option, + /// Present when the name is currently listed for sale. Surfaced so the UI can + /// warn that ownership may be about to change hands. + #[serde(default)] + pub listing: Option, +} + +/// A name's active sale listing, if any (price is in zatoshis). +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Listing { + #[serde(default)] + pub price: Option, + #[serde(default)] + pub pay_taddr: Option, +} + +#[derive(Deserialize)] +struct RpcResponse { + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +#[derive(Deserialize)] +struct RpcError { + code: i64, + message: String, +} + +/// Whether `input` looks like a ZNS name worth trying to resolve (as opposed to a +/// raw Zcash address the user typed). True for a bare label like `alice` or a +/// dotted `alice.zcash`; false for addresses (which contain no `.zcash` and are +/// far longer than a label). Used to decide whether to attempt resolution. +pub fn looks_like_zns_name(input: &str) -> bool { + let t = input.trim(); + if let Some(label) = t.strip_suffix(".zcash") { + return is_valid_label(label); + } + // A bare label that fits the ZNS charset/length and doesn't begin like a + // Zcash address. This is only a UI hint — the send flow resolves a recipient + // by trying to decode it as an address first and only falling back to ZNS — + // so the rare name that happens to start with an address prefix still works + // when entered as `name.zcash`. + is_valid_label(t) && !starts_like_address(t) +} + +/// Whether `s` begins with a Zcash address HRP/prefix. Those prefixes are also +/// valid ZNS-label characters, so a bare label starting with one is treated as a +/// (possible) address rather than a name. +fn starts_like_address(s: &str) -> bool { + const PREFIXES: [&str; 8] = ["u1", "utest", "zs1", "ztest", "t1", "t3", "tm", "tex"]; + PREFIXES.iter().any(|p| s.starts_with(p)) +} + +fn is_valid_label(label: &str) -> bool { + let label = label.trim(); + (1..=62).contains(&label.len()) + && label.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) +} + +/// Normalize user input to a bare ZNS label: drop an optional `.zcash` suffix, +/// trim, and require the `[a-z0-9]{1,62}` charset. Returns `None` if it is not a +/// syntactically valid name. +pub fn normalize_name(input: &str) -> Option { + let t = input.trim(); + let label = t.strip_suffix(".zcash").unwrap_or(t).trim(); + if is_valid_label(label) { + Some(label.to_string()) + } else { + None + } +} + +/// Resolve a ZNS name to its registration via the public indexer. Returns +/// `Ok(None)` when the name is syntactically valid but unregistered, and an error +/// on a bad name or a transport/indexer failure. Touches the network. +pub async fn resolve_name(input: &str, mainnet: bool) -> Result, CoreError> { + let name = normalize_name(input).ok_or_else(|| { + CoreError::Config(format!( + "'{}' is not a valid ZNS name (expected [a-z0-9], 1-62 chars, optionally .zcash)", + input.trim() + )) + })?; + + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "resolve", + "params": [name], + }); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| CoreError::Connection(format!("ZNS client: {e}")))?; + + let response = client + .post(endpoint(mainnet)) + .json(&request) + .send() + .await + .map_err(|e| CoreError::Connection(format!("ZNS resolve request failed: {e}")))?; + + if !response.status().is_success() { + return Err(CoreError::Connection(format!( + "ZNS resolver returned HTTP {}", + response.status() + ))); + } + + let parsed: RpcResponse = response + .json() + .await + .map_err(|e| CoreError::Connection(format!("ZNS resolve response: {e}")))?; + + if let Some(err) = parsed.error { + return Err(CoreError::Connection(format!( + "ZNS resolve error {}: {}", + err.code, err.message + ))); + } + Ok(parsed.result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoints_differ_by_network() { + assert!(endpoint(true).contains("mainnet")); + assert!(endpoint(false).contains("testnet")); + } + + #[test] + fn recognizes_names_but_not_addresses() { + assert!(looks_like_zns_name("alice")); + assert!(looks_like_zns_name("alice.zcash")); + assert!(looks_like_zns_name("bob123")); + // A unified/transparent address is not a name. + assert!(!looks_like_zns_name( + "u1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq" + )); + assert!(!looks_like_zns_name("t1exampletaddr")); + assert!(!looks_like_zns_name("Alice")); // uppercase not allowed + assert!(!looks_like_zns_name("a b")); + } + + #[test] + fn normalizes_and_strips_suffix() { + assert_eq!(normalize_name(" alice ").as_deref(), Some("alice")); + assert_eq!(normalize_name("alice.zcash").as_deref(), Some("alice")); + assert_eq!(normalize_name("ALICE"), None); + assert_eq!(normalize_name(""), None); + assert_eq!(normalize_name(&"a".repeat(63)), None); // too long + assert_eq!(normalize_name(&"a".repeat(62)).as_deref(), Some(&"a".repeat(62)[..])); + } + + #[test] + fn parses_a_resolve_result_ignoring_extra_fields() { + // Shape mirrors the indexer's openrpc `resolve` example. + let json = r#"{ + "name": "alice", + "address": "utest1qqqfff", + "txid": "abc123", + "height": 3901200, + "nonce": 2, + "signature": "AQID", + "last_action": "UPDATE", + "pubkey": null, + "listing": null + }"#; + let r: ResolveResult = serde_json::from_str(json).unwrap(); + assert_eq!(r.name, "alice"); + assert_eq!(r.address, "utest1qqqfff"); + assert_eq!(r.last_action.as_deref(), Some("UPDATE")); + assert!(r.listing.is_none()); + } + + #[test] + fn parses_a_listed_name() { + let json = r#"{"name":"bob","address":"utest1aaa","listing":{"price":100000,"pay_taddr":"t1x"}}"#; + let r: ResolveResult = serde_json::from_str(json).unwrap(); + assert_eq!(r.listing.as_ref().and_then(|l| l.price), Some(100000)); + } +} diff --git a/src-tauri/src/commands/wallet.rs b/src-tauri/src/commands/wallet.rs index 23ef71d..fa81ae7 100644 --- a/src-tauri/src/commands/wallet.rs +++ b/src-tauri/src/commands/wallet.rs @@ -443,6 +443,26 @@ pub async fn wallet_prepare_vote( .await?) } +/// Resolve a ZcashNames (ZNS) name (e.g. `alice` or `alice.zcash`) to a unified +/// address via the public ZNS indexer, using the wallet's configured network. +/// Returns the registration (whose `address` is the send recipient) or `None` +/// when the name is unregistered. +/// +/// The resolver is external infrastructure, so the UI MUST display the resolved +/// address for the user to confirm before sending — a name is a convenience, not +/// an authorization. +#[tauri::command] +pub async fn resolve_zns_name( + state: State<'_, AppState>, + name: String, +) -> AppResult> { + let mainnet = matches!( + network_from_str(&resolve_config(&state).network), + WalletNetwork::Main + ); + Ok(frost_app_core::zns::resolve_name(&name, mainnet).await?) +} + #[derive(Deserialize)] pub struct WalletSendArgs { pub group_id: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index df66f0c..b58bc33 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -87,6 +87,7 @@ pub fn run() { commands::wallet::wallet_new_receive_address, commands::wallet::wallet_prepare_send, commands::wallet::wallet_prepare_vote, + commands::wallet::resolve_zns_name, commands::wallet::wallet_send, commands::wallet::wallet_rebroadcast, commands::server::get_settings, diff --git a/src/ipc/commands.ts b/src/ipc/commands.ts index 568745d..2939caf 100644 --- a/src/ipc/commands.ts +++ b/src/ipc/commands.ts @@ -269,6 +269,21 @@ export const walletPrepareVote = (args: { amount_zatoshis: number; }) => invoke("wallet_prepare_vote", { args }); +// ── ZcashNames (ZNS) resolution ─────────────────────────────────────────── +/** A resolved ZNS registration. `address` is the unified address to pay. */ +export interface ZnsResolution { + name: string; + address: string; + last_action?: string | null; + listing?: { price?: number | null; pay_taddr?: string | null } | null; +} +/** Resolve a ZNS name (e.g. "alice" or "alice.zcash") to a unified address via + * the public indexer. Returns null if unregistered. The caller MUST show the + * resolved address for confirmation before sending — the resolver is external + * infrastructure, not an authorization. */ +export const resolveZnsName = (name: string) => + invoke("resolve_zns_name", { name }); + /** Build, FROST-sign, and (next) broadcast a transfer. Returns the ceremony id; * progress arrives via send:progress / send:complete / send:failed events. */ export const walletSend = (args: { diff --git a/src/screens/Groups.tsx b/src/screens/Groups.tsx index 021c8a6..9d9df68 100644 --- a/src/screens/Groups.tsx +++ b/src/screens/Groups.tsx @@ -19,7 +19,9 @@ import { walletPrepareSend, walletPrepareVote, walletSend, + resolveZnsName, type VoteInput, + type ZnsResolution, walletHistory, walletNotes, walletReceiveAddress, @@ -750,7 +752,46 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo // recipient validation, placeholder, and labeling — the backend decides the // authoritative is_unshield flag from the decoded address. const [sendMode, setSendMode] = useState("shielded"); - const recipientErr = validateRecipient(recipient, isMainnet, sendMode); + + // ZcashNames: a recipient like "alice.zcash" is resolved to a unified address + // via the public indexer. The name is only accepted once resolved, the send + // uses the resolved address, and that address is shown for the user to confirm + // (the resolver is external infrastructure, not an authorization). + const [znsResult, setZnsResult] = useState(null); + const recipientLooksLikeName = /\.zcash\s*$/i.test(recipient); + const resolveZns = useMutation({ + mutationFn: () => resolveZnsName(recipient.trim()), + onSuccess: (r) => { + if (!r) { + setZnsResult(null); + setErr(`No ZcashNames registration found for "${recipient.trim()}".`); + } else { + setZnsResult(r); + setErr(null); + } + }, + onError: (e) => { + setZnsResult(null); + setErr((e as unknown as AppError).message); + }, + }); + // Drop a stale resolution once the field no longer matches the resolved name. + useEffect(() => { + if ( + znsResult && + recipient.trim().replace(/\.zcash$/i, "").toLowerCase() !== znsResult.name.toLowerCase() + ) { + setZnsResult(null); + } + }, [recipient, znsResult]); + + // The address actually sent to: a confirmed ZNS resolution, else the typed + // value. A `.zcash` name that hasn't resolved yet blocks Prepare. + const effectiveRecipient = znsResult?.address ?? recipient.trim(); + const mustResolveName = recipientLooksLikeName && !znsResult; + const recipientErr = mustResolveName + ? null + : validateRecipient(effectiveRecipient, isMainnet, 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 @@ -777,7 +818,7 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo mutationFn: () => walletPrepareSend( group.id, - recipient.trim(), + effectiveRecipient, Math.round(Number(amountZec) * 1e8), sendMode === "shielded" && memo.trim() ? memo.trim() : undefined, ), @@ -1321,6 +1362,44 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo {recipientErr} )} + {/* ZcashNames resolution: a `name.zcash` recipient is looked up + to a unified address, shown here for the user to confirm. */} + {recipientLooksLikeName && ( +
+ {!znsResult ? ( + + ) : ( +
+
+ {znsResult.name}.zcash resolves to — confirm this + is correct before sending: +
+
+ {znsResult.address} +
+ {znsResult.listing && ( +
+ ⚠ This name is currently listed for sale — its owner (and so its + address) may change. Double-check before sending. +
+ )} +
+ )} +
+ )} 0) || + mustResolveName || !!recipientErr } >