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

144 Tests and a Surfpool Devnet

How the Zera SDK got from "scaffolded" to "trustable" — a 144-test Vitest suite, a Surfpool-forked devnet running on a Latitude box, and a quickstart that actually works.

FROM
Dax the Dev <[email protected]>
SOURCE
https://blog.skill-issue.dev/blog/zera_sdk_test_suite/
FILED
2026-03-31 14:40 UTC
REVISED
2026-04-01 20:36 UTC
TIME
6 min read
SERIES
Building the Zera SDK
TAGS
#zera #typescript #testing #vitest #sdk #devnet #surfpool #solana

“add comprehensive test suite for sdk (144 tests)”

That’s 80927, 2026-03-31. Three weeks after the day-one scaffolding shipped, the Zera SDK had 13 test files and 144 individual test cases, all passing under Vitest. Twenty-four hours after that, e350707 added a working hosted devnet, a quickstart guide, and the first end-to-end demo.

This post is about the bridge between “the code exists” and “you can use it without reading the source.”

The shape of the test suite

The 13 test files mirror the SDK’s 13 modules:

constants.test.ts           crypto/keccak.test.ts
crypto/poseidon.test.ts     merkle-tree.test.ts
note-store.test.ts          note.test.ts
pda.test.ts                 prover.test.ts
tx/deposit.test.ts          tx/transfer.test.ts
tx/withdraw.test.ts         utils.test.ts
voucher.test.ts

The reason there’s exactly one test file per source file: it’s the easiest possible discipline to enforce. Open note.ts, see note.test.ts, expect coverage. Open prover.ts, see prover.test.ts, expect coverage. The moment you start putting “shared utility tests” in helpers.test.ts, you lose the ability to look at a file and know whether it’s tested.

A test that catches a regression I couldn’t have predicted

From merkle-tree.test.ts:

describe("MerkleTree", () => {
  it("initializes empty hashes correctly", async () => {
    const tree = await MerkleTree.create(SMALL_HEIGHT);
    // emptyHashes[0] should be 0 (empty leaf)
    expect(tree.emptyHashes[0]).toBe(0n);
    // emptyHashes[1] should be hash(0, 0)
    const expected1 = await poseidonHash2(0n, 0n);
    expect(tree.emptyHashes[1]).toBe(expected1);
  });

  it("root of empty tree matches the top-level empty hash", async () => {
    const tree = await MerkleTree.create(SMALL_HEIGHT);
    let expected = 0n;
    for (let i = 0; i < SMALL_HEIGHT; i++) {
      expected = await poseidonHash2(expected, expected);
    }
    expect(tree.getRoot()).toBe(expected);
  });
});

The “empty hashes” test is the one I’m proudest of. The empty-tree root is one of the most important invariants in a privacy pool: every fresh pool starts with this value, and the on-chain program initializes its tree to this value. If the off-chain SDK and the on-chain program disagree by a single bit, the very first deposit fails because the witness path doesn’t reconcile to the root the program wrote at init.

Without this test, that bug shows up the first time a real user tries to deposit. With this test, it shows up in CI in 220ms.

Test height ≠ production height

Note the constant at the top of the same file:

const SMALL_HEIGHT = 4;

The production TREE_HEIGHT = 24. Building a tree of height 24 in tests is doable but slow — Poseidon over 16M empty-hash slots means tens of seconds per test. Height 4 is 16 leaves. The properties under test (root recomputation, leaf indexing, witness path consistency) are agnostic to height. Test the small case in milliseconds, trust the algebra to scale.

Devnet via Surfpool

The next major commit is e350707 on 2026-04-01: add devnet infrastructure, quickstart guide, and fix shielded pool program ID. This is the commit where I stopped saying “tests pass” and started saying “you can run this.”

The devnet is a Surfpool-forked mainnet — a 1:1 fork of Solana mainnet state with Light Protocol ZK Compression and the Zera shielded pool program deployed on top. From devnet/SETUP.md:

ServiceURLDescription
Solana RPChttp://64.34.82.145:18899JSON-RPC (forked from mainnet)
WebSocketws://64.34.82.145:18900Real-time subscriptions
Surfpool Studiohttp://64.34.82.145Dashboard UI (basic auth)

Why Surfpool over solana-test-validator? Two reasons:

  1. It forks mainnet state. The shielded pool needs to interact with real USDC and SPL token mints. A vanilla test-validator would have me re-creating those by hand. Surfpool just snapshots them.
  2. Light Protocol’s whole stack is already deployed on mainnet. Forking gives me the real programs at the real addresses, not stubs.

A Latitude box hosts the public devnet 24/7. Local devnets work too:

cd devnet
surfpool start --manifest-file-path ./txtx.yml \
  --rpc-url "https://api.mainnet-beta.solana.com"

txtx.yml contains the deploy runbooks. accounts_dump/zera_pool.json and zera_pool.so are the snapshot of the on-chain pool program’s state. The whole devnet boots in under 30 seconds on a fresh box.

The bug the devnet caught

The same commit message says “fix shielded pool program ID.” That bug is the entire reason this commit exists. The SDK’s SHIELDED_POOL_PROGRAM_ID constant in constants.ts was wrong — it pointed at a stale program ID from an early devnet deploy. Every transaction the SDK built was sent to a program that didn’t exist anywhere. Tests pass because tests use mocked PDAs. Devnet caught it the moment a real buildDepositTransaction got submitted.

This is the point of having a devnet at all. Unit tests will tell you that your math is consistent. They will not tell you that your program ID is wrong. Only an end-to-end submission against a real cluster catches that.

What this taught me

The test-to-deploy gap is the most expensive interval in any SDK’s lifecycle. You can have 144 passing tests and still ship a constant pointing at the wrong program. The fix is not “more unit tests.” The fix is one end-to-end test that submits a real transaction to a real cluster and asserts on the response. Surfpool made that possible without a public faucet, without a public RPC, without leaking devnet state to the world.

The other thing this taught me: a 144-test suite for a ~3000-line SDK is roughly the right ratio. Less and you can’t refactor with confidence. Much more and you’re testing the language. Vitest’s parallel runner means the whole suite finishes in ~2 seconds locally; CI runs it on every PR and the latency cost of a regression stays close to zero.

Trade-offs

Why Vitest over Jest? Native ESM, Vite-aligned config, faster start time. Jest’s ESM story has improved but it still feels like a port. Vitest is the default if your project is already in the Vite/Bun half of the ecosystem.

Why ship a hosted devnet at all? Because partners and collaborators are not going to install Surfpool on day one. Giving them an HTTP endpoint that’s already up is the difference between “I’ll try it next week” and “I’m trying it right now.”

Why basic auth on the Studio dashboard? Because it’s a debug UI, not a public service, and exposing the validator state to anonymous internet traffic is a slow rug.

Further reading

← Back to article