Create the client
import {
Nullark,
generateBundledChildDepositGroth16Proof,
generateWithdrawalGroth16Proof,
getCurrentRuntime,
type BundledChildDepositReadResult,
type HexString,
type NullarkCurrentRuntime,
type NullarkOptions,
type ProverRunner
} from "@nullark/sdk";
import {
resolveProverArtifacts,
verifyLocalProverArtifacts
} from "@nullark/sdk/node";
import { groth16 } from "snarkjs";
import { createPublicClient, http, parseAbiItem } from "viem";
const MAX_LOGS_PER_MULTI_BLOCK_QUERY = 20_000;
const COMMITMENT_INSERTED = parseAbiItem(
"event CommitmentInserted(bytes32 indexed commitment, uint256 amount, uint256 indexed leafIndex, bytes encryptedPayload)"
);
export type NullarkChainAdapters = Pick<
NullarkOptions,
"resolveMembership" | "isNullifierSpent" | "runtimeFeeReadClient"
>;
export type NullarkDepositLogClient = {
getChainId(): Promise<number>;
getBlockNumber(): Promise<bigint>;
getLogs(input: {
address: HexString;
event: typeof COMMITMENT_INSERTED;
fromBlock: bigint;
toBlock: bigint;
}): Promise<unknown>;
};
export async function loadProvingContext(artifactDir: string) {
const runtime = getCurrentRuntime();
const artifacts = resolveProverArtifacts(runtime, {
mode: "local-artifact-dir",
artifactDir
});
const artifactBinding = await verifyLocalProverArtifacts(runtime, artifacts);
if (!artifactBinding.trusted) {
throw new Error(`Proving files rejected: ${artifactBinding.reason}`);
}
const proverRunner: ProverRunner<Record<string, unknown>> = {
fullProve: (witness, wasmPath, zkeyPath) =>
groth16.fullProve(witness, wasmPath, zkeyPath)
};
return { runtime, artifacts, artifactBinding, proverRunner };
}
export async function createNullarkClient(input: {
artifactDir: string;
adapters: NullarkChainAdapters;
}): Promise<Nullark> {
const context = await loadProvingContext(input.artifactDir);
const publicClient = createPublicClient({ transport: http(context.runtime.rpcUrl) });
return new Nullark({
runtime: context.runtime,
...input.adapters,
readDeposits: ({ runtime }) =>
readBundledChildDepositEvents(publicClient as unknown as NullarkDepositLogClient, runtime),
localDepositProver: ({ binding }) =>
generateBundledChildDepositGroth16Proof({
binding,
artifacts: context.artifacts,
artifactBinding: context.artifactBinding,
proverRunner: context.proverRunner
}),
localWithdrawalProver: ({ witnessBundle }) =>
generateWithdrawalGroth16Proof({
witness: witnessBundle.witness,
intent: witnessBundle.intent,
artifacts: context.artifacts,
artifactBinding: context.artifactBinding,
proverRunner: context.proverRunner
})
});
}
export async function readBundledChildDepositEvents(
client: NullarkDepositLogClient,
runtime: NullarkCurrentRuntime
): Promise<BundledChildDepositReadResult> {
const chainId = await client.getChainId();
if (chainId !== runtime.chainId) {
throw new Error("Deposit-event reader connected to the wrong chain.");
}
const latestBlock = await client.getBlockNumber();
const deploymentBlock = BigInt(runtime.poolDeploymentBlock);
if (latestBlock < deploymentBlock) {
throw new Error("Deposit-event reader returned a block before the pool deployment.");
}
const logs = await readDepositLogRange(client, runtime, deploymentBlock, latestBlock);
const events: BundledChildDepositReadResult["events"][number][] = [];
const seenLogs = new Set<string>();
for (const value of logs) {
const decoded = decodeDepositLog(value, runtime, deploymentBlock, latestBlock);
if (seenLogs.has(decoded.id)) {
throw new Error("Deposit-event range scan returned a duplicate log.");
}
seenLogs.add(decoded.id);
events.push(decoded.event);
}
return {
source: {
chainId,
pool: runtime.pool,
runtimeId: runtime.runtimeId,
runtimeIdHash: runtime.runtimeIdHash,
templateSetHash: runtime.templateSetHash
},
events
};
}
async function readDepositLogRange(
client: NullarkDepositLogClient,
runtime: NullarkCurrentRuntime,
fromBlock: bigint,
toBlock: bigint
): Promise<readonly unknown[]> {
let logs: unknown;
try {
logs = await client.getLogs({
address: runtime.pool,
event: COMMITMENT_INSERTED,
fromBlock,
toBlock
});
} catch (error) {
if (fromBlock === toBlock || !isOversizedLogQuery(error)) throw error;
return splitLogRange(client, runtime, fromBlock, toBlock);
}
if (!Array.isArray(logs)) throw new Error("Deposit-event reader returned a malformed log array.");
if (fromBlock < toBlock && logs.length >= MAX_LOGS_PER_MULTI_BLOCK_QUERY) {
return splitLogRange(client, runtime, fromBlock, toBlock);
}
return logs;
}
async function splitLogRange(
client: NullarkDepositLogClient,
runtime: NullarkCurrentRuntime,
fromBlock: bigint,
toBlock: bigint
): Promise<readonly unknown[]> {
const midpoint = (fromBlock + toBlock) / 2n;
const left = await readDepositLogRange(client, runtime, fromBlock, midpoint);
const right = await readDepositLogRange(client, runtime, midpoint + 1n, toBlock);
return [...left, ...right];
}
function isOversizedLogQuery(error: unknown): boolean {
let current = error;
for (let depth = 0; depth < 6 && current; depth += 1) {
if (!isRecord(current)) break;
if (current.code === -32020) return true;
if (
typeof current.message === "string" &&
/response too large|query.*too large|more than.*logs|maximum.*logs|context deadline exceeded/i.test(current.message)
) {
return true;
}
current = current.cause;
}
return false;
}
function decodeDepositLog(
value: unknown,
runtime: NullarkCurrentRuntime,
deploymentBlock: bigint,
latestBlock: bigint
) {
if (!isRecord(value)) throw new Error("Deposit-event reader returned a malformed log.");
if (typeof value.address !== "string" || value.address.toLowerCase() !== runtime.pool.toLowerCase()) {
throw new Error("Deposit-event reader returned a log from the wrong pool.");
}
if (value.removed !== false) throw new Error("Deposit-event reader returned a removed log.");
if (
typeof value.blockNumber !== "bigint" ||
typeof value.logIndex !== "number" ||
!Number.isSafeInteger(value.logIndex) ||
value.logIndex < 0
) {
throw new Error("Deposit-event reader returned a malformed log position.");
}
const blockNumber = value.blockNumber;
const logIndex = value.logIndex;
if (blockNumber < deploymentBlock || blockNumber > latestBlock) {
throw new Error("Deposit-event reader returned a log outside the requested block snapshot.");
}
if (typeof value.blockHash !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(value.blockHash)) {
throw new Error("Deposit-event reader returned a malformed block hash.");
}
if (!isRecord(value.args)) throw new Error("Deposit-event reader returned malformed event arguments.");
const commitment = value.args.commitment;
const amount = value.args.amount;
const encryptedPayload = value.args.encryptedPayload;
if (typeof commitment !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(commitment)) {
throw new Error("CommitmentInserted log has a malformed commitment.");
}
if (typeof amount !== "bigint") throw new Error("CommitmentInserted log has a malformed amount.");
if (typeof encryptedPayload !== "string" || !/^0x[0-9a-fA-F]*$/.test(encryptedPayload)) {
throw new Error("CommitmentInserted log has a malformed encrypted payload.");
}
return {
id: `${value.blockHash.toLowerCase()}:${logIndex.toString()}`,
event: {
commitment: commitment as HexString,
amountWei: amount.toString(),
encryptedPayload: encryptedPayload as HexString
}
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
CommitmentInserted from the runtime pool. The three adapters supplied by your app read the remaining chain state:
resolveMembershipreturns the selected note’s accepted Merkle root and path.isNullifierSpentchecks whether that note has already exited.runtimeFeeReadClientreads the pool’s fee state before and after proving.
@nullark/sdk/node for local proving files. Browser integrations keep Node-only imports in trusted tooling and load the main @nullark/sdk entry in the client bundle.
Compatibility
These are the environments exercised by the package-pinned starter.| Surface | Tested setup | Result |
|---|---|---|
| Node | 22.19.0 | Strict tests and the real proof smoke passed |
| TypeScript | 5.9.3, strict mode | Passed with package declarations checked |
| Browser build | Vite 8.0.10 | Production bundle passed; package loading was the browser scope |
| Wallet | Unsigned boundary | Connect and test the wallet inside the host app before release |
| Recovery serialization | UTF-8 JSON roundtrip | Save, parse, and restore passed |
| Local proving | Pinned WASM and zkey files | One deposit plus one withdrawal completed in about 1.8 s on the test machine |
The starter’s deterministic Merkle and proof callbacks are test fixtures. Runtime code uses verified proving files and live adapters.

