> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nullark.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a complete SDK integration

Run Nullark from client setup through deposit, recovery, balance, withdrawal, and final status.

Whole integration, one page. Code below comes from package-pinned starter. Follow order as written.

## Before you start

You need:

- packages from [SDK install](/developers/sdk.md#install);
- proving files named by SDK runtime;
- `resolveMembership`, `isNullifierSpent`, and `runtimeFeeReadClient` adapters;
- private storage for recovery backup;
- wallet submission boundary for unsigned transaction requests.

Use [SDK setup](/developers/sdk/setup.md) for tested environment details. Use [proving files](/developers/proving-artifacts.md) for file checks.

## 1. Create client

Starter reads runtime, checks local proving files, wires `snarkjs`, reads deposit events, then adds three app-owned adapters.

```ts
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);
}
```

`readDeposits` and `isNullifierSpent` return source identity with their values. Membership and fee adapters receive active runtime and must read same pool.

## 2. Prepare a deposit

Create recovery key inside trusted client. Pass same key into `deposits.prepare`.

```ts
import type { Nullark } from "@nullark/sdk";

export function createRecoveryKey(nullark: Nullark): Uint8Array {
  return nullark.recovery.createKey();
}

export async function prepareDeposit(nullark: Nullark, recoveryKey: Uint8Array) {
  if (!(recoveryKey instanceof Uint8Array) || recoveryKey.length !== 32) {
    throw new Error("Recovery key must contain exactly 32 bytes.");
  }

  return nullark.deposits.prepare({
    templateId: "one-as-two-halves",
    recoveryKey
  });
}
```

```ts
const recoveryKey = createRecoveryKey(nullark);
const deposit = await prepareDeposit(nullark, recoveryKey);
```

`recovery.createKey()` returns 32 bytes. `deposits.prepare()` returns recovery data, proof data, and unsigned `transaction`. No chain write has happened yet.

## 3. Save and test recovery

Starter stores recovery key and encrypted envelope in one JSON value. Parse it and run `recovery.restore` before giving deposit request to wallet.

```ts
import type {
  Nullark,
  NullarkRecoveryEnvelope,
  PreparedBundledChildDeposit
} from "@nullark/sdk";

const RECOVERY_SCHEMA = "nullark-sdk-starter-recovery-v1" as const;

export type SavedRecoveryData = Readonly<{
  schema: typeof RECOVERY_SCHEMA;
  recoveryKey: string;
  envelope: NullarkRecoveryEnvelope;
}>;

export function saveRecoveryData(
  deposit: PreparedBundledChildDeposit,
  recoveryKey: Uint8Array
): string {
  if (!(recoveryKey instanceof Uint8Array) || recoveryKey.length !== 32) {
    throw new Error("Recovery key must contain exactly 32 bytes.");
  }

  return JSON.stringify({
    schema: RECOVERY_SCHEMA,
    recoveryKey: bytesToHex(recoveryKey),
    envelope: deposit.recovery.envelope
  } satisfies SavedRecoveryData, null, 2);
}

export function readRecoveryData(raw: string): {
  recoveryKey: Uint8Array;
  envelope: NullarkRecoveryEnvelope;
} {
  let value: unknown;
  try {
    value = JSON.parse(raw);
  } catch {
    throw new Error("Recovery backup is not valid JSON.");
  }

  if (!isRecord(value) || value.schema !== RECOVERY_SCHEMA) {
    throw new Error("Recovery backup uses an unsupported schema.");
  }
  if (typeof value.recoveryKey !== "string" || !/^[0-9a-fA-F]{64}$/.test(value.recoveryKey)) {
    throw new Error("Recovery backup contains an invalid recovery key.");
  }
  if (!isRecord(value.envelope)) {
    throw new Error("Recovery backup is missing its recovery envelope.");
  }

  return {
    recoveryKey: hexToBytes(value.recoveryKey),
    envelope: value.envelope as NullarkRecoveryEnvelope
  };
}

export async function restoreFromRecoveryData(nullark: Nullark, raw: string) {
  const saved = readRecoveryData(raw);
  return nullark.recovery.restore(saved);
}

function bytesToHex(bytes: Uint8Array): string {
  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}

function hexToBytes(hex: string): Uint8Array {
  return Uint8Array.from(hex.match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16));
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return !!value && typeof value === "object" && !Array.isArray(value);
}
```

```ts
const recoveryData = saveRecoveryData(deposit, recoveryKey);
const restoredBundle = await restoreFromRecoveryData(nullark, recoveryData);
```

Keep `recoveryData` in durable private storage. Restore accepts envelope only when it matches active client runtime and recovery key.

## 4. Submit deposit

Pass `deposit.transaction` to app wallet layer after backup roundtrip succeeds.

| Field | Wallet request value |
| --- | --- |
| `chainId` | Client runtime chain |
| `to` | Client runtime pool |
| `value` | Selected deposit amount as `bigint` |
| `data` | Deposit calldata built by SDK |

Review and simulate request, submit once, then keep returned transaction hash. Wait until matching deposit event is available before reading new balance.

## 5. Read the private balance

`balances.get` asks app readers for deposit events and spent state. Matching encrypted deposits open locally with recovery key.

```ts
import type { Nullark } from "@nullark/sdk";

export function readPrivateBalance(nullark: Nullark, recoveryKey: Uint8Array) {
  if (!(recoveryKey instanceof Uint8Array) || recoveryKey.length !== 32) {
    throw new Error("Recovery key must contain exactly 32 bytes.");
  }

  return nullark.balances.get({ recoveryKey });
}
```

```ts
const balance = await readPrivateBalance(nullark, recoveryKey);
```

`availableBalanceWei` is decimal wei string. `deposits` contains recovered bundles and their notes. Spent notes remain visible with status `"spent"`; their amounts stay out of `availableBalanceWei`.

```ts
const depositWithFunds = balance.deposits.find(({ notes }) =>
  notes.some(({ status }) => status === "available")
);
const note = depositWithFunds?.notes.find(({ status }) => status === "available");

if (!depositWithFunds || !note) {
  throw new Error("No available Nullark note.");
}
```

Reader failure rejects call. Keep previous displayed balance and show refresh action in app UI.

## 6. Prepare a withdrawal

Use bundle and child index from same balance result. Pass recipient plus fee bounds user reviewed.

```ts
import type { HexString, Nullark } from "@nullark/sdk";

type RestoredBundle = Awaited<ReturnType<Nullark["recovery"]["restore"]>>;

export function prepareWithdrawal(input: {
  nullark: Nullark;
  bundle: RestoredBundle;
  childIndex: number;
  destination: HexString;
  maxFeeWei: string;
  minNetAmountWei: string;
}) {
  return input.nullark.withdrawals.prepare({
    bundle: input.bundle,
    childIndex: input.childIndex,
    destination: input.destination,
    maxFeeWei: input.maxFeeWei,
    minNetAmountWei: input.minNetAmountWei
  });
}
```

```ts
const withdrawal = await prepareWithdrawal({
  nullark,
  bundle: depositWithFunds.bundle,
  childIndex: note.childIndex,
  destination,
  maxFeeWei,
  minNetAmountWei
});
```

`withdrawals.prepare` resolves membership, checks note status, reads fee, builds proof, then checks fee and note status again. Result contains exact gross amount, fee, recipient amount, destination, and unsigned `transaction`.

Review these fields before submission:

| Field | Meaning |
| --- | --- |
| `destination` | Recipient address |
| `grossAmountWei` | Selected note amount |
| `feeWei` | Fee used by proof |
| `netAmountWei` | Amount recipient gets |
| `maxFeeWei` | Highest fee accepted by request |
| `minNetAmountWei` | Lowest recipient amount accepted by request |

Submit `withdrawal.transaction` through app transaction boundary. Keep returned hash.

## 7. Reconcile the result

Wallet or RPC timeout can leave submission status unclear. Check original hash when present, then check exact prepared operation.

```ts
import type { HexString } from "@nullark/sdk";

export type SubmissionState = "confirmed" | "reverted" | "submission-uncertain";

export async function reconcileSubmission(input: {
  transactionHash?: HexString;
  readReceipt: (transactionHash: HexString) => Promise<{ status: "success" | "reverted" } | null>;
  // Match the exact deposit commitment or withdrawal nullifier for this operation.
  effectObserved: () => Promise<boolean>;
}): Promise<SubmissionState> {
  if (input.transactionHash !== undefined && !/^0x[0-9a-fA-F]{64}$/.test(input.transactionHash)) {
    throw new Error("Transaction hash must be 32 bytes.");
  }

  let receiptStatus: "success" | "reverted" | undefined;
  if (input.transactionHash !== undefined) {
    try {
      receiptStatus = (await input.readReceipt(input.transactionHash))?.status;
    } catch {
      // The effect check below can still settle an unavailable receipt lookup.
    }
  }

  let effectReadSucceeded = false;
  try {
    effectReadSucceeded = true;
    if (await input.effectObserved()) return "confirmed";
  } catch {
    effectReadSucceeded = false;
    // Keep the result uncertain until either lookup becomes available.
  }

  if (receiptStatus === "reverted" && effectReadSucceeded) return "reverted";
  return "submission-uncertain";
}
```

For deposit, `effectObserved` checks `deposit.binding.commitment`. For withdrawal, it checks `withdrawal.nullifier`.

| Result | App action |
| --- | --- |
| `confirmed` | Mark complete and refresh balance |
| `reverted` | Show failure and wait for corrected input |
| `submission-uncertain` | Keep original hash and check again later |

Starter has no resend branch. New submission starts only after original result is known.

## Keep between sessions

Keep recovery JSON and any unresolved transaction hash. Recovery JSON contains secret key material.

> **Warning:**
> Anyone holding recovery backup can restore its private notes. Keep it out of logs, analytics, URLs, support messages, and shared clipboard history.

Need field-level details? Open [API reference](/developers/sdk/api.md). Need focused guides? Use [deposit](/developers/sdk/deposit.md), [recovery](/developers/sdk/recovery.md), [withdrawal](/developers/sdk/withdrawal.md), and [errors and status](/developers/sdk/errors.md).
