Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 27 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
46 changes: 22 additions & 24 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>:<ua>:<sig>[:<pubkey>]` 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
Expand Down
1 change: 1 addition & 0 deletions src-tauri/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
241 changes: 241 additions & 0 deletions src-tauri/core/src/zns.rs
Original file line number Diff line number Diff line change
@@ -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:<name>:<ua>:<sig>[:<pubkey>]` where the
//! signature is base64 Ed25519 over everything between `ZNS:` and `:<sig>`. 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<String>,
/// 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<Listing>,
}

/// 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<u64>,
#[serde(default)]
pub pay_taddr: Option<String>,
}

#[derive(Deserialize)]
struct RpcResponse {
#[serde(default)]
result: Option<ResolveResult>,
#[serde(default)]
error: Option<RpcError>,
}

#[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<String> {
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<Option<ResolveResult>, 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));
}
}
20 changes: 20 additions & 0 deletions src-tauri/src/commands/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<frost_app_core::zns::ResolveResult>> {
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,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading