7 releases (4 breaking)
| 0.6.0 | Mar 13, 2026 |
|---|---|
| 0.4.0 | Mar 9, 2026 |
| 0.3.0 | Mar 9, 2026 |
| 0.2.2 | Mar 1, 2026 |
| 0.1.0 | Nov 25, 2025 |
#3 in #light-client
Used in 3 crates
1MB
13K
SLoC
zync-core
Trust-minimized Zcash light client primitives. Verification, scanning, proving. Everything a light client needs to sync the Orchard shielded pool without trusting a server.
Trust model
zync minimizes trust in the server. Every claim is verified cryptographically:
hardcoded activation hash
│
▼
┌─────────────┐ ligerito polynomial commitment
│ header chain │────────────────────────────────────── proven NOMT roots
│ proof │ O(log n) verification, (tree_root, nullifier_root,
└─────────────┘ no headers transmitted actions_commitment)
│ │
├────────────────────────┬────────────────────────────┤
▼ ▼ ▼
┌───────────┐ ┌────────────┐ ┌─────────────┐
│ commitment│ │ nullifier │ │ actions │
│ proofs │ │ proofs │ │ commitment │
└───────────┘ └────────────┘ │ chain │
NOMT sparse NOMT sparse └─────────────┘
merkle proof: merkle proof: SHA256 merkle root
note exists spent/unspent per block, Blake2b
in global tree status running chain
│ │ │
▼ ▼ ▼
received notes balance is server didn't omit,
are real correct insert, or reorder
actions in any block
What's verified:
-
Header chain. Block headers encoded into a trace polynomial, proven with ligerito. The verifier checks the proof in O(log n) without seeing any headers. Chain continuity (prev_hash linkage), difficulty, and height progression are enforced by trace constraints.
-
State proofs. NOMT sparse merkle proofs for note commitments and nullifiers. Each proof's root is bound to the header-proven tree root. Commitment proofs verify received notes exist. Nullifier proofs verify spent/unspent status.
-
Actions integrity. Running Blake2b chain over per-block orchard action merkle roots. Checked against the header-proven value. Detects any tampering with the compact block action data the server sends during sync.
-
Cross-verification. BFT majority (>2/3) consensus against independent lightwalletd nodes. Tip and activation block hashes compared across providers. Prevents single-server eclipse attacks.
-
Trial decryption. After decrypting a note, the commitment is recomputed from the decrypted fields and compared to the server-provided cmx. A malicious server cannot forge ciphertexts that decrypt to notes with arbitrary values.
What's NOT verified (scope boundaries):
- Proof-of-work. Headers are committed, not validated for PoW. The header proof proves the server's chain is internally consistent and matches the activation anchor. It doesn't re-validate every block.
- Sapling pool. Only Orchard is supported.
Modules
| Module | Purpose |
|---|---|
verifier |
Ligerito header chain proof verification (epoch + tip, parallel) |
nomt |
NOMT sparse merkle proof verification for commitments and nullifiers |
actions |
Per-block actions merkle root and running commitment chain |
scanner |
Orchard trial decryption with cmx verification (native + WASM parallel) |
sync |
Sync verification primitives: header proof validation, commitment/nullifier batch verification, cross-verify consensus, memo ciphertext extraction |
prover |
Ligerito proof generation from header chain traces |
trace |
Header chain trace encoding (headers → polynomial) |
client |
gRPC clients for zidecar and lightwalletd (feature-gated) |
Usage
Sync verification (light client)
use zync_core::{sync, Scanner};
// 1. verify header proof → extract proven NOMT roots
let proven = sync::verify_header_proof(&proof_bytes, tip, /*mainnet=*/true)?;
// 2. scan compact blocks
let scanner = Scanner::from_fvk(&fvk);
let notes = scanner.scan(&actions);
// 3. verify received notes exist (NOMT commitment proofs)
sync::verify_commitment_proofs(&proofs, &cmxs, &proven, &server_root)?;
// 4. verify nullifier status (NOMT nullifier proofs)
let spent = sync::verify_nullifier_proofs(&nf_proofs, &nfs, &proven, &nf_root)?;
// 5. verify block actions weren't tampered with
sync::verify_actions_commitment(&running, &proven.actions_commitment, true)?;
Proof generation (server)
use zync_core::{trace, prover};
// encode headers into trace polynomial
let mut trace = trace::encode_trace(
&headers, &state_roots,
[0u8; 32], [0u8; 32], // initial commitments
tip_tree_root, tip_nullifier_root, final_actions_commitment,
)?;
// generate proof (auto-selects config based on trace size)
let proof = prover::HeaderChainProof::prove_auto(&mut trace)?;
let bytes = proof.serialize_full()?;
Note scanning
use zync_core::{Scanner, ScanAction, Scope};
// from full viewing key (external scope, received notes)
let scanner = Scanner::from_fvk(&fvk);
// scan sequentially (WASM) or in parallel (native with rayon)
let found = scanner.scan(&actions);
// batch scanner tracks nullifiers across blocks
let mut batch = BatchScanner::from_fvk(&fvk);
for block in blocks {
batch.scan_block(block.height, &block.actions);
}
println!("balance: {} zat", batch.unspent_balance());
Cross-verification
use zync_core::sync::{hashes_match, CrossVerifyTally};
// compare block hashes (handles LE/BE byte order)
assert!(hashes_match(&zidecar_hash, &lightwalletd_hash));
// BFT tally
let tally = CrossVerifyTally { agree: 4, disagree: 1 };
assert!(tally.has_majority()); // 4/5 > 2/3
Memo extraction
use zync_core::sync::extract_enc_ciphertext;
// extract 580-byte encrypted ciphertext from raw V5 transaction
if let Some(enc) = extract_enc_ciphertext(&raw_tx, &cmx, &epk) {
// decrypt with orchard try_note_decryption using your orchard version
// ...
}
Features
| Feature | Default | Description |
|---|---|---|
client |
yes | gRPC clients for zidecar and lightwalletd |
parallel |
yes | Rayon-based parallel note scanning |
wasm |
no | WASM bindings (wasm-bindgen, console_error_panic_hook) |
wasm-parallel |
no | WASM + parallel (requires SharedArrayBuffer) |
WASM build
# single-threaded
cargo build --target wasm32-unknown-unknown \
--no-default-features --features wasm
# multi-threaded (requires COOP/COEP headers for SharedArrayBuffer)
RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals' \
cargo build --target wasm32-unknown-unknown \
--no-default-features --features wasm-parallel \
-Z build-std=panic_abort,std
Cross-verification endpoints
Default mainnet endpoints from two independent operators, geographically distributed:
| Endpoint | Provider | Region |
|---|---|---|
na.zec.rocks |
zec.rocks | North America |
eu.zec.rocks |
zec.rocks | Europe |
ap.zec.rocks |
zec.rocks | Asia Pacific |
us.zec.stardust.rest |
Chainsafe | US |
eu.zec.stardust.rest |
Chainsafe | Europe |
jp.zec.stardust.rest |
Chainsafe | Japan |
Available as zync_core::client::CROSSVERIFY_MAINNET.
Wire formats
Header proof
[epoch_full_size: u32 LE]
[epoch_full_proof]
[tip_full_proof]
Each full proof:
[public_outputs_len: u32 LE]
[public_outputs: bincode ProofPublicOutputs]
[log_size: u8]
[ligerito_proof: bincode FinalizedLigeritoProof]
Trace layout
32 fields per header (BinaryElem32 = 4 bytes each):
| Fields | Content |
|---|---|
| 0 | height |
| 1-8 | block_hash (32 bytes) |
| 9-16 | prev_hash (32 bytes) |
| 17 | nBits |
| 18 | cumulative_difficulty |
| 19 | running commitment |
| 20-23 | sapling_root (epoch boundaries) |
| 24-27 | orchard_root (epoch boundaries) |
| 28-29 | nullifier_root (epoch boundaries) |
| 30 | state_commitment |
| 31 | reserved |
Sentinel row (24 fields after last header):
| Fields | Content |
|---|---|
| 0-7 | tip_tree_root |
| 8-15 | tip_nullifier_root |
| 16-23 | final_actions_commitment |
License
MIT
Dependencies
~18–25MB
~418K SLoC