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.
Summary
zera-sdk is a single git repository (Dax911/zera-sdk) containing three artifacts:
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.crates/zera-neon—neon-rsNode bindings that re-exportzera-coreto JavaScript without a WASM hop.packages/sdk(@zera-labs/sdk) — the TypeScript surface developers consume. Contains a high-levelZeraClient, transaction builders, an encryptedNoteStore, a Groth16 prover wrapper, and the IDL-typed Anchor client.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, andsolana-program 1.18types (Cargo.toml). - Web wallets and dApps want a TypeScript package they can
pnpm add @zera-labs/sdkand use against@solana/web3.jswithout 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 teston every PR. Today the SDK has 144 Vitest cases (test suite commit) and the Rust tests live alongsidezera-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-windowsare covered. Linux ARM64 (Raspberry Pi-class) is unbuilt. TODO: Dax confirm whether prebuild matrix expansion is in scope before 0.2.
Open questions
- Do circuits live in this repo or a sibling? Today the circuit
.wasmand.zkeyfiles 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/circuitspackage with content-addressed releases. - Should the MCP server own its note store? Right now the MCP server has an in-memory note store as a placeholder (see
noteStoreinpackages/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. - How does a browser dApp get fast Poseidon? Currently the SDK exports
computeCommitmentfrom a snarkjs-flavoured bundle in the browser path. Awasm-bindgenbuild ofzera-corewould close that gap, but it doubles the SDK size in browser bundles. TODO: Dax confirm whether a separate@zera-labs/sdk-browserpackage 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-coreand re-export throughcrates/zera-neonif a TS caller will need them. - Add TS changes to
packages/sdk; if a constant changes, update bothconstants.rsandconstants.tsin the same commit. - Add MCP-shaped changes to
packages/mcp-serveronly when the SDK function the tool wraps has stabilised.
A migration off this shape — to, say, three separate repos — would require:
- Promoting the parity test between
constants.rsandconstants.tsto a cross-repo CI workflow that fails the PR if either side bumps without the other. - Replacing the
pnpm -r buildergonomic with a release-please-style multi-repo orchestrator. - 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_PATHfrom 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 inpackages/mcp-server/README.md. Out of scope for this RFC.
References
- Dax911/zera-sdk — the repo this RFC describes.
- Init commit
af8cc28— when the shape was chosen. Cargo.toml,pnpm-workspace.yaml— the workspace definitions.packages/mcp-server/README.md— the MCP surface.- Building the Zera SDK: Day One — narrative version of this decision.
- The MCP server inside zera-sdk — companion post on why the MCP server is a first-class crate.