DOC# ZERASD SLUG zera_sdk_scaffolding PRINTED 2026-05-06 03:47 UTC

Building the Zera SDK: Day One

Sixteen commits in fourteen minutes. The first day of the @zera-labs/sdk monorepo — Rust core via neon-rs, TypeScript scaffolding, Poseidon, Merkle trees, ZK provers, and an MCP server for AI agents.

FROM
Dax the Dev <[email protected]>
SOURCE
https://blog.skill-issue.dev/blog/zera_sdk_scaffolding/
FILED
2026-03-05 21:54 UTC
REVISED
2026-03-05 21:57 UTC
TIME
8 min read
SERIES
Building the Zera SDK
TAGS
#zera #typescript #rust #sdk #zk #poseidon #mcp #solana

“init monorepo structure”

That commit message — af8cc28, 2026-03-05T21:54:29Z — is when the Zera SDK began. Sixteen commits later, fourteen minutes after, the scaffolding was done: a Rust crate, a Neon native binding, a TypeScript SDK with Poseidon + Merkle + provers + transaction builders, and an MCP server. The whole arc is visible on the commit log — every commit dated within the same minute, every commit doing exactly one thing.

This post is about how the day-one scaffolding was structured, why I split it into 16 atomic commits, and what each piece actually does.

The shape of the monorepo

Three packages from the start:

packages/
  zera-core/      # Rust crate — circuit-aligned crypto primitives
  zera-bindings/  # Neon-rs node bindings exposing zera-core to JS
  sdk/            # @zera-labs/sdk — TypeScript SDK
  mcp-server/     # @zera-labs/mcp-server — MCP tools for AI agents

The reason zera-core exists in Rust is that the on-chain Solana program is also in Rust, and the SDK has to compute Poseidon commitments and Groth16 proof formatting exactly the way the on-chain verifier does. JS and Rust agreeing on a 254-bit field element is the kind of thing that goes wrong silently. Moving the canonical implementation to Rust and exposing it to JS via Neon kept the two halves bitwise consistent.

The TypeScript SDK is what 95% of users will touch. The MCP server is the bet that the next class of “user” will be an AI agent, not a human in a wallet popup.

Atomic commits as a design discipline

If you scan the commit log, you’ll see this pattern from af8cc28 through e350707:

af8cc286  init monorepo structure
7ba37e6e  add zera-core rust crate
d605b930  add neon-rs node bindings for zera-core
4aaa8def  add ts sdk package scaffolding + crypto primitves
26f77955  add note mgmt, merkle tree, pda helpers, utils
f713bb3f  add zk prover + voucher system
cd518d5a  add transaction builders for deposit/withdraw/transfer
0debc6af  add tree state client for fetching merkle tree from chain
f4beda30  add ZeraClient high-level wrapper + NoteStore
27786470  update barrel exports w new modules
d150f829  add mcp server package for ai agent integration
98bc0a88  add core sdk documentation
dc2937ae  add agentic integration guide + use cases
af03daf2  add examples, security doc, and current status analysis

Each commit is one logical concept and nothing else. The reason this matters: when you’re scaffolding a 200-file SDK in a single session, the sane way to bisect a regression two months later is to git revert a single concept. If the Merkle tree breaks, you revert 26f77955. If the prover wires wrongly, you revert f713bb3f. If you ship the whole thing as one mega-commit, you can’t isolate.

It also makes the SDK reviewable. There’s no “Initial commit (12,000 lines).” You can read it in 14 minutes the way I wrote it in 14 minutes.

The crypto layer

The cryptographic foundation is in packages/sdk/src/crypto/poseidon.ts. Poseidon is the hash function we use everywhere — for note commitments, for Merkle nodes, for nullifiers. It’s circuit-friendly, which means it’s cheap to prove inside a Groth16 circuit. SHA-256 inside a circuit is thousands of constraints. Poseidon is dozens.

import { buildPoseidon } from "circomlibjs";

let poseidonInstance: any = null;

export async function getPoseidon(): Promise<any> {
  if (!poseidonInstance) poseidonInstance = await buildPoseidon();
  return poseidonInstance;
}

export async function poseidonHash(inputs: bigint[]): Promise<bigint> {
  const poseidon = await getPoseidon();
  const hash = poseidon(inputs.map((v: bigint) => poseidon.F.e(v)));
  return BigInt(poseidon.F.toObject(hash));
}

export async function poseidonHash2(left: bigint, right: bigint): Promise<bigint> {
  return poseidonHash([left, right]);
}

The singleton is load-bearing. buildPoseidon initializes WASM that takes ~80ms cold. If every Merkle node hash had to spin that up, building a tree with TREE_HEIGHT = 24 would take 30 seconds.

Notes are bigints all the way down

From types.ts:

export interface Note {
  amount:   bigint;
  asset:    bigint;
  secret:   bigint;
  blinding: bigint;
  memo:     [bigint, bigint, bigint, bigint];
}

export interface StoredNote extends Note {
  commitment: bigint;
  nullifier:  bigint;
  leafIndex:  number;
}

Every field is a bigint. The reason: every field has to be reducible mod BN254 prime to enter a circuit, and that’s a 254-bit operation. JS Number is 53 bits. Using bigint from day one means every constant in the SDK is correct as written:

export const BN254_PRIME = BigInt(
  "21888242871839275222246405745257275088548364400416034343698204186575808495617",
);

The cost of bigint everywhere is that you can’t Math.max your way out of a comparison. The benefit is that you can never lose a low bit by accident.

createNote: the most important six lines

function randomFieldElement(): bigint {
  const bytes = randomBytes(31); // 248 bits – safely below the 254-bit prime
  const value = BigInt("0x" + bytes.toString("hex"));
  return value % BN254_PRIME;
}

export function createNote(amount, asset, memo?): Note {
  return {
    amount, asset,
    secret:   randomFieldElement(),
    blinding: randomFieldElement(),
    memo:     memo ?? [0n, 0n, 0n, 0n],
  };
}

The note’s secret is what derives the nullifier. If you can predict it, you can predict the nullifier, and your transaction is forensically linkable. Sampling 248 bits and reducing mod the BN254 prime is the standard recipe; sampling 256 bits would bias the distribution slightly toward small field elements after the modular reduction.

Transaction builders: the SDK’s actual surface area

From tx/deposit.ts:

export function buildDepositTransaction(params: DepositParams): Transaction {
  const { payer, mint, amount, commitment, proof, publicInputs, programId } = params;
  // Derive PDAs
  const [poolConfig] = derivePoolConfig(mint, programId);
  const [merkleTree] = deriveMerkleTree(mint, programId);
  const [vault]      = deriveVault(mint, programId);
  // ...
}

Three transaction builders: buildDepositTransaction, buildWithdrawTransaction, buildTransferTransaction. Each one consumes a Groth16 proof + commitment, derives the right PDAs, and returns an unsigned Transaction. The signing is intentionally not the SDK’s job. That’s the wallet’s job, and embedding signing in an SDK is what gives you a tarball of leaked keys six months later.

ZeraClient: the high-level wrapper

By the time we got to f4beda30 — add ZeraClient high-level wrapper + NoteStore, the lower-level pieces were composable enough that one class could orchestrate them. The wrapper takes a config:

export interface ZeraClientConfig {
  rpcUrl: string;
  programId?: string;
  circuits: {
    deposit:  CircuitPaths;
    withdraw: CircuitPaths;
    transfer: CircuitPaths;
  };
  noteStore?: NoteStore;
  cacheEndpoint?: string;
}

…and exposes one method per high-level operation. client.deposit(amount, mint), client.withdraw(commitment), client.transfer(amount, recipient). Behind each method is the pipeline: fetch tree state → load relevant circuit WASM → prove → build tx → return unsigned Transaction for the wallet to sign.

NoteStore is an interface with one default in-memory implementation and a contract that says “if you persist notes, you’re responsible for not leaking them.” Most consumers will plug an encrypted file backend. The wallet demo plugs Tauri’s filesystem with Argon2id-derived keys; we’ll get to that in the Zera Wallet v3 post.

MCP: betting on agents

The most experimental thing on day one was @zera-labs/mcp-server. From packages/mcp-server/src/index.ts:

const server = new McpServer({ name: "zera-protocol", version: "0.1.0" });

server.tool(
  "zera_deposit",
  "Deposit USDC into the ZERA shielded pool. Funds become private and untraceable after deposit.",
  {
    amount: z.number().positive().describe("Amount of USDC to deposit (e.g., 100.50)"),
    memo:   z.string().optional().describe("Optional memo for your records (stored privately, never on-chain)"),
  },
  async ({ amount, memo }) => { /* … */ },
);

If the only thing that talks to your protocol is wallets, your TAM is “humans who installed an extension.” If MCP-connected agents can also call your protocol, your TAM is “every Claude/Cursor/Cline session anyone runs.” That’s a 100× delta. The bet is cheap — mcp-server is one ~400-line file plus the SDK it depends on. If agents end up not using zk-shielded pools, I lose 400 lines. If they do, I get there first.

Trade-offs

Why circomlibjs instead of a hand-rolled Poseidon? Because circomlib is the canonical implementation that the circuits are written against. Re-implementing Poseidon for the host is exactly the kind of “I’ll save 50ms” choice that fails an end-to-end test in week three.

Why Neon instead of WASM for zera-core? Because the SDK ships to Node and to a Tauri webview, both of which natively support .node files. WASM would have meant another loader, another fetch, another async boundary. Neon is one require.

Why ship the MCP server in the same monorepo? Because the moment you give it its own repo, it falls behind on SDK changes. Same monorepo, same pnpm-workspace.yaml, same lockfile. One pnpm install and you’re done.

What this taught me

Atomic commits are the difference between an SDK that’s reviewable and an SDK that’s trusted. Every dependency relationship in the scaffolding above is one-directional and one-commit-at-a-time. That’s why the 144-test test suite (80927) that landed three weeks later could be written without rewriting any of the underlying code — see the next post.

Further reading

← Back to article