skip to content
Skill Issue Dev | Dax the Dev
search
← all docs updated

RFC 001: zera-sdk monorepo shape

Why @zera-labs/sdk ships as a tri-split — Rust core (zera-core), TypeScript surface (@zera-labs/sdk), and a Model Context Protocol server — instead of a single npm package or a single Rust crate.

accepted Endorsed. Implementation may be in progress.

by Dax the Dev

Summary

zera-sdk is a single git repository (Dax911/zera-sdk) containing three artifacts:

  1. crates/zera-core — pure-Rust crate, no Solana toolchain assumed at build time. Holds the cryptographic primitives (Poseidon, Merkle tree, note construction, nullifier derivation, PDA helpers, verifier glue). Targets BN254.
  2. crates/zera-neonneon-rs Node bindings that re-export zera-core to JavaScript without a WASM hop.
  3. packages/sdk (@zera-labs/sdk) — the TypeScript surface developers consume. Contains a high-level ZeraClient, transaction builders, an encrypted NoteStore, a Groth16 prover wrapper, and the IDL-typed Anchor client.
  4. packages/mcp-server (@zera-labs/mcp-server) — a stdio Model Context Protocol server that re-exposes the SDK as four tools (zera_deposit, zera_transfer, zera_withdraw, zera_balance) for AI agents.

This RFC captures why the tri-split exists, why it is accepted rather than proposed, and what the off-ramp looks like if the shape stops being justified.

Motivation

A privacy SDK on Solana has three audiences with incompatible build constraints:

  • Solana on-chain programs want pure Rust, no tokio, predictable allocation, and solana-program 1.18 types (Cargo.toml).
  • Web wallets and dApps want a TypeScript package they can pnpm add @zera-labs/sdk and use against @solana/web3.js without thinking about Rust.
  • AI agents want a discoverable surface — they read tool descriptions, not docstrings, and a wallet primitive is most useful to them when it is not an SDK at all but an MCP server they can call directly.

Shipping these as one artifact would force whichever audience is last in line to pay the abstraction tax of the others. Shipping them as three separate repos would fork the version, the test plan, and (most painfully) the cryptographic constants.

Detailed design

Constraint: cryptographic constants live in exactly one place

Every layer needs to agree on the BN254 prime, the Merkle tree height (24 → 16,777,216 leaves), the root-history window (100 historical roots for concurrent-access safety), the seeds for the Anchor PDAs, and the USDC mint as a 32-byte field element. Today these live in crates/zera-core/src/constants.rs and the TypeScript layer mirrors them in packages/sdk/src/constants.ts with a parity test.

That parity test is the load-bearing seam: if it ever fails, a wallet would generate a commitment the chain refuses, and the bug would manifest as “deposits silently disappear”. The monorepo is what makes that test cheap to run on every PR.

Constraint: notes are too sensitive to round-trip through WASM

The note structure (note.rs):

pub struct Note {
    pub amount: u64,
    pub asset: [u8; 32],
    pub secret: [u8; 32],
    pub blinding: [u8; 32],
    pub memo: [[u8; 32]; 4],
}

secret and blinding must never leak. The Poseidon hash that produces the commitment runs at sub-millisecond speeds in native Rust; in WASM it runs in tens of milliseconds because of bigint marshalling. neon-rs gives us the Rust path on Node and Electron without giving up zero-copy Buffer semantics. The browser still gets a WASM build (TODO: Dax confirm — currently the browser path is a separate snarkjs bundle and the neon path is Node/Electron only).

Constraint: the MCP server is a product, not an example

The MCP server is published as @zera-labs/mcp-server with its own README documenting Claude Desktop config. It is the surface AI agents see, and it must be runnable as npx -y @zera-labs/mcp-server with zero local Rust toolchain. That requires the SDK to ship its dependencies (circuits, zkeys, IDL) in a way the MCP server can resolve at runtime via env vars (ZERA_DEPOSIT_WASM_PATH, etc.).

This is why the MCP server lives inside the SDK monorepo and not in a separate zera-mcp repo: the day a circuit is regenerated, the SDK and the MCP server need to bump together.

Layout

zera-sdk/
├── Cargo.toml                    # workspace = [crates/zera-core, crates/zera-neon]
├── pnpm-workspace.yaml           # packages = packages/*, demos/*
├── crates/
│   ├── zera-core/                # pure Rust primitives
│   └── zera-neon/                # Node bindings
├── packages/
│   ├── sdk/                      # @zera-labs/sdk (TS)
│   └── mcp-server/               # @zera-labs/mcp-server (TS)
├── demos/                        # consumer examples (pnpm workspace member)
└── devnet/                       # Surfpool-forked devnet config

The pnpm-workspace.yaml includes demos/*, which is how the integration tests for the SDK consume the SDK without relying on npm registry round-trips.

Alternatives considered

A1: One npm package, WASM-only

Pros: fewer artifacts, browser-friendly by default, no native build step. Cons: native callers (Electron wallet, Node MCP server) pay the WASM tax on every Poseidon hash, and serializing 32-byte field arrays across the WASM boundary is a known footgun. The project explicitly chose neon-rs to avoid this — see the add neon-rs node bindings for zera-core commit (d605b93).

Status: rejected.

A2: Three separate repos (zera-core, zera-sdk-ts, zera-mcp)

Pros: independent release cadence, smaller blast radius per change. Cons: cryptographic-constant drift becomes silent, integration tests need a fan-out CI, contributors need to clone three repos to land a feature that touches the proof input shape. The project rejected this on day one — see the init monorepo structure commit.

Status: rejected.

A3: SDK as a Rust-only crate, TS callers wrap manually

Pros: zero TypeScript build complexity in the upstream. Cons: pushes the “how do I build a Groth16 proof from JS” problem to every dApp author. Existing Solana dApps live in TypeScript; a Rust-only SDK would mean nobody actually uses it.

Status: rejected.

A4: MCP server in a separate repo that depends on @zera-labs/sdk

Pros: clear separation of concerns; MCP server can be updated without bumping the SDK version. Cons: circuits and the SDK move together — a new transfer.zkey requires both packages to bump in lockstep. Cross-repo lockstep releases are the worst kind.

Status: rejected.

Drawbacks

  • Build matrix is wide. cargo test + cargo build --release + pnpm -r build + pnpm -r test on every PR. Today the SDK has 144 Vitest cases (test suite commit) and the Rust tests live alongside zera-core. CI minutes are non-trivial.
  • Contributor onboarding requires both Rust and Node toolchains. Mitigated by the CONTRIBUTING.md but real.
  • neon-rs binaries must be prebuilt for each platform. Today only x86_64-darwin, aarch64-darwin, x86_64-linux, x86_64-windows are covered. Linux ARM64 (Raspberry Pi-class) is unbuilt. TODO: Dax confirm whether prebuild matrix expansion is in scope before 0.2.

Open questions

  1. Do circuits live in this repo or a sibling? Today the circuit .wasm and .zkey files are referenced by env-var paths in the MCP server, not committed to the SDK package. If we ship circuits in @zera-labs/sdk, the package size jumps to multi-MB. If we ship them via a separate package or a CDN, we need a verification pipeline. TODO: Dax confirm — leaning toward a separate @zera-labs/circuits package with content-addressed releases.
  2. Should the MCP server own its note store? Right now the MCP server has an in-memory note store as a placeholder (see noteStore in packages/mcp-server/src/index.ts). Production needs an Argon2id + AES-256-GCM encrypted-at-rest store at ~/.zera/notes.enc. Open question: does that encrypted-store implementation live in @zera-labs/sdk (so any consumer can use it) or in @zera-labs/mcp-server (because only the MCP server runs as a long-lived process with disk access)? Leaning toward the SDK.
  3. How does a browser dApp get fast Poseidon? Currently the SDK exports computeCommitment from a snarkjs-flavoured bundle in the browser path. A wasm-bindgen build of zera-core would close that gap, but it doubles the SDK size in browser bundles. TODO: Dax confirm whether a separate @zera-labs/sdk-browser package is the migration target.

Adoption

This shape is accepted. It is the actual shape of the repo as of af8cc28 (init) through e350707 (current main). New contributors should:

  • Add Rust changes to crates/zera-core and re-export through crates/zera-neon if a TS caller will need them.
  • Add TS changes to packages/sdk; if a constant changes, update both constants.rs and constants.ts in the same commit.
  • Add MCP-shaped changes to packages/mcp-server only when the SDK function the tool wraps has stabilised.

A migration off this shape — to, say, three separate repos — would require:

  1. Promoting the parity test between constants.rs and constants.ts to a cross-repo CI workflow that fails the PR if either side bumps without the other.
  2. Replacing the pnpm -r build ergonomic with a release-please-style multi-repo orchestrator.
  3. Re-homing the demos/* workspace, which currently relies on the monorepo to consume SDK changes without a registry round-trip.

The cost of that migration is the upper bound on how much pain we are willing to absorb from staying in the monorepo.

Security considerations

  • Cryptographic-constant drift is the highest-severity failure mode. The monorepo + parity test is the mitigation. Removing the monorepo without a replacement parity mechanism would re-open this.
  • neon-rs binaries are prebuilt and published to npm. They are a supply-chain target. We should sign and SLSA-attest the prebuilt binaries. TODO: Dax confirm — currently unsigned; tracked separately from this RFC.
  • The MCP server reads ZERA_WALLET_PATH from env. That is a deliberate choice (the MCP server should never embed key material) but it means the MCP server inherits whatever ambient authority the AI agent has. Documented in packages/mcp-server/README.md. Out of scope for this RFC.

References