Skip to content

Repository files navigation

@chainvue/verus-sapling

npm CI license node

Offline signing of Verus shielded (Sapling) transactionst→z, z→z, z→t, with encrypted memos. It builds and signs bytes; your consumer broadcasts. No full node on the signing host.

Companion to @chainvue/verus-sdk (transparent transactions). Where the SDK stays pure-TS and tiny, this package adds the one thing shielded signing genuinely needs — a Sapling zk-prover — compiled to WASM and kept opt-in.

  • 🔐 All three shielded flows: shield (t→z), private send (z→z), deshield (z→t), each with a ZIP-302 memo.
  • 🧾 Real zk-proving in WASM (sapling-crypto → Groth16/BLS12-381), RedJubjub spend-auth & binding signatures, note encryption.
  • 🛰️ Client-side note detection — trial-decrypts compact blocks; no z_listunspent, no wallet daemon.
  • 🌐 Browser-ready — a gRPC-web transport and Web Worker prover ship; a runnable MV3 extension broadcasts a private z→z from Chrome.
  • 💰 bigint satoshis end-to-end — one checked crossing into float64, never a silent number.
  • Daemon-verified — the byte-layout bar is acceptance by a real Verus daemon, not self-consistent tests.

Warning

Proven on testnet. Before mainnet or real funds, read SECURITY.md — the full spending key is present on the signing host (the same trust surface as a transparent WIF).

Contents

Install

npm install @chainvue/verus-sapling

The compiled WASM prover is shipped in the package (crate/pkg/). You supply the two Sapling proving parameters (~50 MB, fetched once) at runtime.

Quick start

Load the prover, then use the lightwalletd-driven orchestration. Chain data comes through a LightwalletdTransport; the concrete Node gRPC client lives on the ./lightwalletd subpath (the package root pulls in no gRPC).

import { detectNotes, buildShieldedSpend, initSapling } from '@chainvue/verus-sapling';
import { LightwalletdClient } from '@chainvue/verus-sapling/lightwalletd';

await initSapling(wasmBytes);                       // load the wasm module once
const client = new LightwalletdClient('lightwalletd:9077');

// 1. Find the wallet's own notes — no z_listunspent, no full node.
const notes = await detectNotes(client, detectProver, {
  key: { dfvkHex },              // a viewing key is enough to scan (recommended)
  fromHeight: walletBirthday,    // toHeight defaults to the chain tip
});

// 2. Spend one detected note (z→z / z→t) with a memo.
const { hex } = await buildShieldedSpend(client, spendProver, {
  note: { txid: notes[0].txid, outputIndex: notes[0].outputIndex, extskHex },
  shieldedOutputs: [{ address, valueSats, memo }],   // valueSats is a bigint
});
await client.sendTransaction(hex);

detectProver / spendProver are thin callbacks you wire to the WASM builders (detectNotes on the main thread; spendShielded in a Web Worker for the ~20 s prove — see examples/extension). Memos are string, ≤ 512 bytes. For t→z shielding, call the shieldT2z builder directly.

Scan with a viewing key, spend with the spending key. detectNotes needs only a Diversifiable Full Viewing Key (dfvkHex); load the full spending key (extskHex) only to sign a spend. See SECURITY.md.

How it works

The Rust crate (crate/) is compiled to WASM and owns the parts that must be byte-exact: the ZIP-243 sighash, the v4 transaction serializer, Sapling proving, the signatures, and note encryption. The TypeScript layer (src/) stays thin: input validation, the bigint money invariant, the lightwalletd transport, and address/key marshalling.

Verus = stock Zcash Sapling

Confirmed against Verus source and a live mainnet node: Verus shielded is stock Zcash Sapling — unmodified zcash/librustzcash circuit, byte-identical MPC parameters, stock consensus branch id 0x76b809bb, version group id 0x892f2085, tx v4. Consensus is frozen at Sapling on both mainnet and testnet (Canopy — which would gate ZIP-212 — is not in Verus's upgrade set), so ZIP-212 enforcement is Off. The only Verus-specific value in the entire path is that branch id, injected into the sighash. Even the lightwalletd wire protocol is stock — VerusCoin/lightwalletd's protos are byte-identical to Zcash's.

Recovery phrases: one phrase, two unrelated key schedules

A Verus Mobile wallet derives both of its keys from a single recovery phrase — by two derivations that share nothing:

transparent (R…) shielded (zs…)
derivation sha256(utf8(phrase)) + Agama/Iguana clamp BIP-39 → ZIP-32
BIP-39 ignored — the phrase is hashed as text required, real PBKDF2
path none — one key per phrase, no HD m/32'/coin'/account'
network mainnet ≡ testnet coin type 133 on both (see below)
lives in @chainvue/verus-sdk keys.seedToWif deriveSaplingAccount (here)
import { deriveSaplingAccount, initSapling } from '@chainvue/verus-sapling';

await initSapling(wasmBytes);
const account = await deriveSaplingAccount({ mnemonic: phrase }); // coinType 133, account 0
account.address;   // zs… — compare against your wallet before relying on it
account.extskHex;  // spending key, the form every builder here takes
account.dfvkHex;   // viewing key — enough to scan, cannot spend

Consequences worth internalizing: the phrase must be a valid BIP-39 mnemonic for a z-address to exist at all (Verus Mobile gates its shielded account on validateMnemonic() + ≥12 words, while the transparent side accepts any string), so the same words produce unrelated keys on the two sides.

Coin type 133 on both networks — including VRSCTEST. Verus Mobile's parseDlightSeed calls Tools.deriveSaplingSpendingKey(seed) with no network argument, so the Kotlin bridge's networks.getOrDefault(network, ZcashNetwork.Mainnet) falls back to mainnet and a testnet wallet holds a mainnet-path key. Verified 2026-07-28 against a live VRSCTEST wallet: of five candidate paths, only m/32'/133'/0' reproduced the address the app shows. The default here matches that; COIN_TYPE_VRSCTEST (1) exists for stock-zcash tooling and derives a key no Verus Mobile wallet holds. The address HRP is zs on both networks either way.

Backend: the one unavoidable dependency

  • t→z (shielding) needs no commitment-tree witness — only the transparent UTXOs you already fetch. The true "sign privately with zero extra backend" case.
  • z→z / z→t spend shielded notes, which need each note's Merkle **witness
    • anchor** and note detection. Those come from a chain-scanning service — a stock Verus lightwalletd (standard gRPC GetBlockRange / GetTreeState). The signing host still runs no full node, but this service is required. Browsers additionally need a gRPC-web proxy in front of it.

Proving parameters

The prover needs two parameter files — the canonical Zcash Sapling MPC parameters, byte-identical for Verus. They are not bundled (≈50 MB would bloat every install); fetch them once and pass their bytes to initSapling and the prover.

file size SHA-256
sapling-spend.params ~47 MB 8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13
sapling-output.params ~3.5 MB 2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4

Always verify the SHA-256. Get them from any Zcash full node (zcutil/fetch-params.sh), a local Verus install, or your own host — then cache them (IndexedDB / Cache API in the browser, the filesystem in Node). Non-canonical params produce proofs the daemon rejects.

Security

This library signs money and the spending key is present on the signing host. SECURITY.md documents the trust model in full — what is guaranteed (the key never crosses the network, is never logged, and is not in the signed tx) and what is not (WASM memory is not zeroized; host/supply-chain compromise). Report vulnerabilities privately via a GitHub security advisory, never a public issue.

Daemon-verified on testnet — txids

All three flows were built by this code and accepted by a Verus testnet daemon:

  • t→z 1edf8aa6…6623 (native) and d142edf8…a0ef (built in WASM)
  • z→z 53ea99fc…89feb — fully private, recipient received 9.9999
  • z→t 86951c8d…bd8a — transparent recipient received 0.05
  • z→z, fully lightwalletd-sourced 07e3b38e…f996 — every byte of chain data from lightwalletd, signed in WASM, no full node

Note detection is cross-checked against consensus: the nullifier it predicts for a note matches the on-chain nullifier of the spend that later consumed it, byte-for-byte. The ZIP-243 serializer/sighash is regression-tested against daemon-made golden vectors (cargo test).

Examples

  • examples/extension — a runnable MV3 browser extension: detect notes, read the inbox, and broadcast a private z→z from Chrome, proving in a Web Worker.
  • examples/messenger — an end-to-end shielded-memo messenger: framed messages carried in zero-value notes.

Project layout

src/            TypeScript: validation, money, wallet orchestration, transport
  browser/      gRPC-web transport + Web Worker prover (no external gRPC dep)
crate/          Rust: ZIP-243 sighash, v4 serializer, Sapling proving
  pkg/          committed WASM build (so a fresh clone works without Rust)
proto/          stock Zcash lightwalletd gRPC definitions
examples/       runnable extension + messenger demos
test/           vitest suite (money, hex, protobuf, gRPC-web, wallet, zaddr, …)

Docs: SECURITY · CONTRIBUTING · RELEASING · NOTICE

Contributing

Contributions are welcome — see CONTRIBUTING.md. The gate is npm run buildnpm run typechecknpm test, plus cargo test for the crate. Commits follow Conventional Commits (they drive the automated release).

License

Apache-2.0. See LICENSE. Third-party provenance for the WASM prover's bundled Rust crates, the vendored lightwalletd protos, and the proving parameters is in NOTICE.

About

Offline signing of Verus shielded (Sapling) transactions — t→z, z→z, z→t, with memo. WASM prover, no full node on the signing host.

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages