DEV Community

Cover image for Build a privacy-preserving notebook DApp on Midnight
Oladeji Tosin
Oladeji Tosin

Posted on

Build a privacy-preserving notebook DApp on Midnight

Introduction

Most blockchains are public ledgers, everything stored on them is visible to every participant in the network. That works well for token balances and governance votes, but it creates a real problem the moment you want to store anything sensitive. A note-taking application built naively on a public chain would expose every note body to every observer in plain text.

Midnight solves this through zero-knowledge proofs. Instead of storing your note text on-chain, you store only a cryptographic commitment, a SHA-256 hash that proves a note exists without revealing what it says. The full text lives in an off-chain backend your application controls. Any observer who reads the chain sees a list of hashes; only the notebook owner, who holds the secret key, can write new notes or delete existing ones, and only the backend can produce the matching body for any given commitment.

In this tutorial, you build the Private Notebook DApp from scratch. You write a Compact smart contract that enforces ownership and manages commitments, implement the TypeScript witnesses that feed private data to ZK circuits, set up an Express and SQLite backend for off-chain storage, and connect everything to a React frontend through the 1AM wallet. By the end you have a working full-stack DApp running on Midnight's preprod network that you can extend into any privacy-preserving application.


Prerequisites

Before you start, make sure you have the following installed and configured:


Step 1: Understand the architecture

Before writing any code, it helps to see how the pieces fit together.

Architecture diagram showing the privacy-preserving notebook DApp layers: browser frontend, Express backend, notebook API, and Midnight preprod network

What goes on-chain: A 32-byte commitment hash for each note, a counter, and the owner's derived public key. None of these values reveals note content.

What stays off-chain: The actual note text, stored in SQLite keyed by (contractAddress, commitment).

How ownership works: The deployer's 32-byte secret key never leaves the browser. The contract stores persistentHash(["notebook:pk:", secretKey]). Every write operation re-derives this value inside the ZK circuit and asserts it equals the stored commitment. An observer sees only the hash.

This split between on-chain and off-chain storage is the core design pattern for any Midnight DApp that needs to handle data larger than a hash. Storing raw text on-chain would expose it to all network participants and be expensive, as every node must store it permanently. Storing only the commitment keeps costs low and preserves privacy: the hash proves the note exists and that the same data was committed, but reveals nothing about the content. The backend is your access layer, not a trust anchor; the chain is the trust anchor.

Understanding this distinction before you write any code will help you make good decisions throughout the rest of the tutorial. Ask yourself for each piece of data: does a network observer need to verify this, or just you? If just you, keep it off-chain. If the network needs to verify it (ownership, existence, ordering), commit a hash to the chain.


Step 2: Explore the monorepo layout

The project is an npm workspaces monorepo with four packages. Each package has a single, focused responsibility:

  • @notebook/contract contains the Compact smart contract source, the TypeScript witness implementations, and the compiled managed artifacts. Nothing outside this package needs to know the Compact syntax.
  • @notebook/api is a browser-safe TypeScript library that wraps the Midnight SDK. It exposes deployNotebook, joinNotebook, and the note operations as plain async functions. The frontend imports from here, not from the SDK directly.
  • @notebook/backend is an Express HTTP server with a SQLite database. It stores note bodies indexed by commitment. It has no knowledge of Midnight or ZK proofs; it is a simple content-addressed store.
  • @notebook/frontend is the React and Vite web application. It imports from @notebook/api and makes HTTP requests to the backend. All user interaction flows through here.

Keeping these packages separate means you can swap the backend for a different storage layer (IPFS, a database-as-a-service, client-side encryption) without touching the contract or the API layer. Similarly, you could add a CLI client that imports @notebook/api without touching the frontend at all.

The root package.json defines the workspaces and top-level scripts:

{
  "name": "fullstack-midnight-dapp",
  "version": "0.1.0",
  "private": true,
  "description": "Private Notebook: a full-stack Midnight dApp tutorial",
  "workspaces": ["contract", "api", "backend", "frontend"],
  "scripts": {
    "compile:contract": "npm run compile -w contract",
    "build": "npm run build -w contract && npm run build -w api && npm run build -w backend && npm run build -w frontend",
    "dev:backend": "npm run dev -w backend",
    "dev:frontend": "npm run dev -w frontend"
  },
  "engines": {
    "node": ">=22"
  }
}
Enter fullscreen mode Exit fullscreen mode

Create tsconfig.base.json at the root. Every workspace tsconfig.json extends this file so compiler settings stay consistent across the monorepo:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "sourceMap": true,
    "resolveJsonModule": true,
    "isolatedModules": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Create scripts/copy-managed.mjs at the root. The contract's build script runs this after tsc compiles the TypeScript sources. TypeScript emits output to contract/dist/, but the Compact compiler writes the managed artifacts (the generated index.ts, verifier keys, and circuit IR files) into contract/src/managed/. This script copies that directory across so that consumers resolving @notebook/contract package imports find the artifacts under dist/managed/ where they expect them:

// After tsc emits contract/dist, copy the compiled Compact artifacts from
// contract/src/managed to contract/dist/managed so that consumers resolving
// package imports (dist/index.js → "./managed/...") find the files.
import { cpSync, existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const here = dirname(fileURLToPath(import.meta.url));
const src = resolve(here, "../contract/src/managed");
const dst = resolve(here, "../contract/dist/managed");

if (!existsSync(src)) {
  console.error(
    `copy-managed: ${src} does not exist. Run 'npm run compile -w contract' first.`
  );
  process.exit(1);
}

cpSync(src, dst, { recursive: true });
console.log(`copy-managed: copied ${src} -> ${dst}`);
Enter fullscreen mode Exit fullscreen mode

⚠️WARNING
copy-managed.mjs exits with an error if contract/src/managed does not exist. Always run compact compile (Step 6) before running npm run build. The compile step creates the managed directory; the build step copies it.


ℹ️ INFO
Your editor will show TypeScript "cannot find module" errors and red squiggles throughout Steps 2 to 20. This is expected. node_modules does not exist until you run npm install in Step 21, and the managed contract artifacts do not exist until you run compact compile in Step 6. The code you write is correct; the errors disappear once you complete Step 21.

Step 3: Write the Compact smart contract

Create contract/package.json. This declares the workspace name, the compile and build scripts, and the Midnight SDK packages the contract layer needs:

{
  "name": "@notebook/contract",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    },
    "./managed": "./dist/managed/notebook/contract/index.js"
  },
  "scripts": {
    "compile": "compact compile src/notebook.compact src/managed/notebook",
    "build": "tsc -p tsconfig.json && node ../scripts/copy-managed.mjs",
    "test": "vitest run"
  },
  "dependencies": {
    "@midnight-ntwrk/compact-js": "^2.5.0",
    "@midnight-ntwrk/compact-runtime": "^0.15.0"
  },
  "devDependencies": {
    "typescript": "^5.5.4",
    "vitest": "^2.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Create contract/tsconfig.json. It extends the root base config and excludes the managed/ directory (generated code that TypeScript should not try to compile directly from source):

{
  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*.ts"],
  "exclude": ["src/managed", "node_modules", "dist"]
}
Enter fullscreen mode Exit fullscreen mode

Create contract/src/notebook.compact with the following complete source:

pragma language_version 0.22;

import CompactStandardLibrary;

// Public ledger state. Stored on chain, visible to any observer.
export sealed ledger owner_commitment: Bytes<32>;
export ledger notes: Map<Bytes<32>, Bytes<32>>;
export ledger note_count: Counter;

// Witness declarations. Implementations live in TypeScript.
witness local_secret_key(): Bytes<32>;
witness fresh_nonce(): Bytes<32>;

// Domain separated public key derivation. Midnight does not provide a
// public_key() builtin, so we derive one with persistentHash.
pure circuit derive_pk(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "notebook:pk:"), sk]);
}

// The constructor pins the owner to whoever deployed the contract.
constructor() {
  const sk = local_secret_key();
  owner_commitment = disclose(derive_pk(sk));
}

// post_note stores a content commitment on chain under a fresh note id.
// Only the original deployer can call this because we check the derived
// public key against the stored owner_commitment.
export circuit post_note(content_hash: Bytes<32>): Bytes<32> {
  const sk = local_secret_key();
  assert(disclose(derive_pk(sk) == owner_commitment), "Only owner can post");

  const nonce = fresh_nonce();
  const note_id = disclose(
    persistentHash<Vector<2, Bytes<32>>>([nonce, content_hash])
  );
  const d_hash = disclose(content_hash);

  notes.insert(note_id, d_hash);
  note_count.increment(1);
  return note_id;
}

// delete_note removes an owned note. The same owner check applies.
export circuit delete_note(note_id: Bytes<32>): [] {
  const sk = local_secret_key();
  assert(disclose(derive_pk(sk) == owner_commitment), "Only owner can delete");

  const d_id = disclose(note_id);
  assert(notes.member(d_id), "Note does not exist");

  notes.remove(d_id);
  note_count.decrement(1);
}
Enter fullscreen mode Exit fullscreen mode

Key concepts in this file:

  • sealed ledger means the constructor sets the field once and no circuit can ever change it afterward. This guarantees the owner is locked at deploy time.
  • witness declarations are TypeScript functions, not Compact code. The keyword tells the compiler "a TypeScript function called local_secret_key will provide this 32-byte value at proving time."
  • pure circuit derive_pk is a helper with no ledger side effects. It derives a public key using persistentHash (Poseidon hash) with a domain separator, because Midnight has no public_key() built-in.
  • disclose() wraps every value derived from witness data before it touches the ledger. Compact enforces this rule at compile time to prevent accidental private-data leaks.
  • The return type [] is an empty tuple, Compact's equivalent of void.

⚠️CAUTION
Forgetting disclose() on any witness-derived value that flows into a ledger operation produces the compile-time error: "implicit disclosure of witness value". Always wrap such values before inserting them into maps, assigning to ledger fields, or using them in assertions.


Step 4: Implement TypeScript witnesses

Create contract/src/witnesses.ts:

import type { WitnessContext } from "@midnight-ntwrk/compact-runtime";
import type { Ledger } from "./managed/notebook/contract/index.js";

export type NotebookPrivateState = {
  readonly secretKey: Uint8Array;
};

export const createNotebookPrivateState = (
  secretKey: Uint8Array
): NotebookPrivateState => {
  if (secretKey.length !== 32) {
    throw new Error(`secretKey must be 32 bytes, got ${secretKey.length}`);
  }
  return { secretKey };
};

export const generateRandomSecretKey = (): Uint8Array => {
  const buf = new Uint8Array(32);
  crypto.getRandomValues(buf);
  return buf;
};

export const witnesses = {
  local_secret_key: ({
    privateState,
  }: WitnessContext<Ledger, NotebookPrivateState>): [
    NotebookPrivateState,
    Uint8Array
  ] => [privateState, privateState.secretKey],

  fresh_nonce: ({
    privateState,
  }: WitnessContext<Ledger, NotebookPrivateState>): [
    NotebookPrivateState,
    Uint8Array
  ] => {
    const nonce = new Uint8Array(32);
    crypto.getRandomValues(nonce);
    return [privateState, nonce];
  },
};
Enter fullscreen mode Exit fullscreen mode

NotebookPrivateState is the private data the Midnight SDK holds in memory for this contract instance. Every witness function receives a WitnessContext containing privateState and returns a tuple [newPrivateState, witnessOutput]. These witnesses do not mutate private state, so they return it unchanged. fresh_nonce uses the Web Crypto API, which is available in both browsers and Node.js 20+.

It is worth pausing here to understand the witness execution model. When you call a circuit (for example post_note), the Midnight SDK does not send the witness values to any server. Instead, it runs the witness functions locally in your browser's JavaScript engine, collects their outputs, and feeds them into the ZK proving system alongside the circuit logic. The prover generates a proof that the witness outputs satisfy the circuit's constraints. That proof, and only that proof, is sent to the network. The secret key and the nonce stay on your device.

This is the fundamental privacy guarantee of the architecture: the ZK proof certifies that "someone who knows the secret key corresponding to this public key has posted a note with this commitment" without revealing the key itself, the nonce, or the note content. Witnesses are the bridge between the private world (your browser) and the public world (the chain).


Step 5: Export the compiled contract

Create contract/src/index.ts. This barrel file binds the compiled artifacts, the witnesses, and the contract class into a single CompiledNotebookContract object that the SDK's deployContract and findDeployedContract functions accept:

import { CompiledContract } from "@midnight-ntwrk/compact-js";
import * as ManagedNotebook from "./managed/notebook/contract/index.js";
import { witnesses, type NotebookPrivateState } from "./witnesses.js";

export * from "./witnesses.js";
export * from "./managed/notebook/contract/index.js";
export {
  Contract as NotebookContract,
  ledger as notebookLedger,
  pureCircuits as notebookPureCircuits,
} from "./managed/notebook/contract/index.js";

// CompiledNotebookContract is the compact-js CompiledContract binding used by
// the v4 SDK's deployContract / findDeployedContract functions.
export const CompiledNotebookContract = CompiledContract.make<
  ManagedNotebook.Contract<NotebookPrivateState>
>("Notebook", ManagedNotebook.Contract<NotebookPrivateState>).pipe(
  CompiledContract.withWitnesses(witnesses),
  CompiledContract.withCompiledFileAssets("./managed/notebook")
);
Enter fullscreen mode Exit fullscreen mode

The managed/notebook/contract/index.js file does not exist yet. The Compact compiler generates it in the next step.

CompiledContract.make creates a binding that tells the SDK the contract's name and class. The two pipe calls attach the witness implementations and the compiled file assets (the path to the managed/notebook directory where the .verifier and .bzkir files live). Every time the SDK needs to generate a proof or find verifier keys, it reads these assets from disk in Node.js environments or fetches them over HTTP in browser environments.


Step 6: Compile the contract

The Compact compiler reads notebook.compact and generates a set of managed TypeScript artifacts, ZK verifier keys (.verifier files), and circuit IR files (.bzkir files).

Run the compiler directly from inside the contract/ directory. At this point in the tutorial npm install has not been run yet, so the workspace shortcut is not available:

cd contract
compact compile src/notebook.compact src/managed/notebook
cd ..
Enter fullscreen mode Exit fullscreen mode

ℹ️ INFO

Once you run npm install in Step 21, the same command is available as npm run compile:contract. You only need to run it again if you change notebook.compact. If you follow this tutorial straight through without modifying the contract, the artifacts generated here are still valid when you reach Step 21.

After the compiler completes, the directory contract/src/managed/notebook/ exists and contains:

managed/notebook/
+-- contract/
|   +-- index.ts          (typed contract, ledger accessor, and pure circuits)
+-- keys/
|   +-- post_note.verifier
|   +-- delete_note.verifier
+-- zkir/
    +-- post_note.bzkir
    +-- delete_note.bzkir
Enter fullscreen mode Exit fullscreen mode

The keys/ and zkir/ directories contain the ZK key material. The Vite dev server must serve these files at {origin}/keys/{circuit}.verifier and {origin}/zkir/{circuit}.bzkir. The midnight-zk-assets Vite plugin in vite.config.ts handles this automatically.

*💡 TIP
Any time you change notebook.compact, re-run npm run compile:contract. The managed artifacts must match the deployed contract; mismatches cause "Failed to configure verifier key" errors at runtime.


Step 7: Define the common API types

Create api/package.json. The API layer depends on every Midnight SDK package that a browser DApp needs, plus the @notebook/contract workspace package:

{
  "name": "@notebook/api",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": "./dist/index.js"
  },
  "scripts": {
    "build": "tsc -p tsconfig.json"
  },
  "dependencies": {
    "@midnight-ntwrk/compact-js": "^2.5.0",
    "@midnight-ntwrk/compact-runtime": "^0.15.0",
    "@midnight-ntwrk/dapp-connector-api": "^4.0.1",
    "@midnight-ntwrk/ledger-v8": "^8.0.3",
    "@midnight-ntwrk/midnight-js-contracts": "^4.0.4",
    "@midnight-ntwrk/midnight-js-fetch-zk-config-provider": "^4.0.4",
    "@midnight-ntwrk/midnight-js-http-client-proof-provider": "^4.0.4",
    "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "^4.0.4",
    "@midnight-ntwrk/midnight-js-network-id": "^4.0.4",
    "@midnight-ntwrk/midnight-js-types": "^4.0.4",
    "@midnight-ntwrk/midnight-js-utils": "^4.0.4",
    "@notebook/contract": "0.1.0",
    "rxjs": "^7.8.1"
  },
  "devDependencies": {
    "typescript": "^5.5.4"
  }
}
Enter fullscreen mode Exit fullscreen mode

Create api/tsconfig.json. The lib array includes DOM because the API layer uses crypto.subtle and fetch, which are browser globals:

{
  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "lib": ["ES2022", "DOM"]
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}
Enter fullscreen mode Exit fullscreen mode

Create api/src/common-types.ts. This file centralises all shared TypeScript types so the providers and API modules stay decoupled:

import type { ContractAddress } from "@midnight-ntwrk/compact-runtime";
import type { MidnightProviders } from "@midnight-ntwrk/midnight-js-types";
import type { FoundContract } from "@midnight-ntwrk/midnight-js-contracts";
import type { Contract, Witnesses, Ledger } from "@notebook/contract";
import type { NotebookPrivateState } from "@notebook/contract";
import type { Observable } from "rxjs";

export const NotebookPrivateStateId = "notebookPrivateState";
export type NotebookPrivateStateIdType = typeof NotebookPrivateStateId;

export type NotebookContractType = Contract<
  NotebookPrivateState,
  Witnesses<NotebookPrivateState>
>;

// Circuit keys are the string keys of the provableCircuits map on the contract.
export type NotebookCircuitKeys = Exclude<
  keyof NotebookContractType["impureCircuits"],
  number | symbol
>;

export type NotebookProviders = MidnightProviders<
  NotebookCircuitKeys,
  NotebookPrivateStateIdType,
  NotebookPrivateState
>;

export type DeployedOrFoundNotebook = FoundContract<NotebookContractType>;

export type NotebookDerivedState = {
  readonly ownerCommitment: Uint8Array;
  readonly notes: ReadonlyMap<string, string>;
  readonly noteCount: bigint;
};

export type NotebookAPI = {
  readonly deployedContractAddress: ContractAddress;
  readonly state$: Observable<NotebookDerivedState>;
  postNote: (contentHash: Uint8Array) => Promise<Uint8Array>;
  deleteNote: (noteId: Uint8Array) => Promise<void>;
};

export type { Ledger, NotebookPrivateState };
Enter fullscreen mode Exit fullscreen mode

MidnightProviders is parameterised by three types: the circuit key union (which circuits can be proved), the private state ID string literal, and the private state shape. NotebookCircuitKeys derives those keys directly from the compiled contract so you never have to maintain them by hand.

The type system earns its keep here. Because NotebookCircuitKeys is derived from impureCircuits on the compiled contract class, if you add a new circuit to notebook.compact and recompile, TypeScript will report an error anywhere that NotebookProviders is used without handling the new circuit key. This compile-time safety prevents the common mistake of adding a circuit but forgetting to register its ZK key material with the providers.


Step 8: Write the in-memory private state provider

Create api/src/in-memory-private-state-provider.ts. The Midnight SDK stores private state (the owner's secret key) through a PrivateStateProvider interface. This implementation keeps everything in memory, which is sufficient for a tutorial. Production apps should use midnight-js-level-private-state-provider backed by IndexedDB with password protection.

import type {
  ContractAddress,
  SigningKey,
} from "@midnight-ntwrk/compact-runtime";
import type {
  PrivateStateId,
  PrivateStateProvider,
  PrivateStateExport,
  ExportPrivateStatesOptions,
  ImportPrivateStatesOptions,
  ImportPrivateStatesResult,
  SigningKeyExport,
  ExportSigningKeysOptions,
  ImportSigningKeysOptions,
  ImportSigningKeysResult,
} from "@midnight-ntwrk/midnight-js-types";

// A simple in-memory PrivateStateProvider for browser usage. Private state is
// scoped by contract address and private state ID. State is lost on page reload,
// which is acceptable for a tutorial dApp. Production apps should use
// midnight-js-level-private-state-provider with proper password management.
export const inMemoryPrivateStateProvider = <
  PSI extends PrivateStateId,
  PS
>(): PrivateStateProvider<PSI, PS> => {
  const privateStates = new Map<string, PS>();
  const signingKeys = new Map<string, SigningKey>();
  let currentAddress = "";

  const psKey = (id: PSI) => `${currentAddress}:${String(id)}`;

  return {
    setContractAddress(address: ContractAddress): void {
      currentAddress = address;
    },

    async set(id: PSI, state: PS): Promise<void> {
      privateStates.set(psKey(id), state);
    },
    async get(id: PSI): Promise<PS | null> {
      return privateStates.get(psKey(id)) ?? null;
    },
    async remove(id: PSI): Promise<void> {
      privateStates.delete(psKey(id));
    },
    async clear(): Promise<void> {
      for (const k of privateStates.keys()) {
        if (k.startsWith(`${currentAddress}:`)) privateStates.delete(k);
      }
    },

    async setSigningKey(
      address: ContractAddress,
      key: SigningKey
    ): Promise<void> {
      signingKeys.set(address, key);
    },
    async getSigningKey(address: ContractAddress): Promise<SigningKey | null> {
      return signingKeys.get(address) ?? null;
    },
    async removeSigningKey(address: ContractAddress): Promise<void> {
      signingKeys.delete(address);
    },
    async clearSigningKeys(): Promise<void> {
      signingKeys.clear();
    },

    async exportPrivateStates(
      _options?: ExportPrivateStatesOptions
    ): Promise<PrivateStateExport> {
      return { states: [], version: 1 } as unknown as PrivateStateExport;
    },
    async importPrivateStates(
      _exportData: PrivateStateExport,
      _options?: ImportPrivateStatesOptions
    ): Promise<ImportPrivateStatesResult> {
      return { imported: 0 } as unknown as ImportPrivateStatesResult;
    },

    async exportSigningKeys(
      _options?: ExportSigningKeysOptions
    ): Promise<SigningKeyExport> {
      return { keys: [] } as unknown as SigningKeyExport;
    },
    async importSigningKeys(
      _exportData: SigningKeyExport,
      _options?: ImportSigningKeysOptions
    ): Promise<ImportSigningKeysResult> {
      return { imported: 0 } as unknown as ImportSigningKeysResult;
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

Step 9: Build the providers factory

Create api/src/providers.ts. This file assembles the full MidnightProviders object from a connected wallet and exports the wallet discovery and connection helpers that the frontend uses:

// dapp-connector-api v4 types. The package ships a proper ESM build so we can
// import from it directly without inlining the declarations.
import type {
  ConnectedAPI,
  InitialAPI,
} from "@midnight-ntwrk/dapp-connector-api";
import {
  Binding,
  type FinalizedTransaction,
  Proof,
  SignatureEnabled,
  Transaction,
  type TransactionId,
} from "@midnight-ntwrk/ledger-v8";
import { FetchZkConfigProvider } from "@midnight-ntwrk/midnight-js-fetch-zk-config-provider";
import { httpClientProofProvider } from "@midnight-ntwrk/midnight-js-http-client-proof-provider";
import { createProofProvider } from "@midnight-ntwrk/midnight-js-types";
import { indexerPublicDataProvider } from "@midnight-ntwrk/midnight-js-indexer-public-data-provider";
import { fromHex, toHex } from "@midnight-ntwrk/compact-runtime";
import { setNetworkId } from "@midnight-ntwrk/midnight-js-network-id";
import { inMemoryPrivateStateProvider } from "./in-memory-private-state-provider.js";
import type { UnboundTransaction } from "@midnight-ntwrk/midnight-js-types";

import {
  NotebookPrivateStateId,
  type NotebookCircuitKeys,
  type NotebookProviders,
} from "./common-types.js";

export type { ConnectedAPI, InitialAPI };

export type InjectedWallet = {
  uuid: string;
  name: string;
  icon: string;
  apiVersion: string;
  api: InitialAPI;
};

export type ConnectedWallet = {
  readonly connectedApi: ConnectedAPI;
};

// Discover all compliant Midnight wallets from window.midnight (multi-wallet
// discovery pattern, UUID keyed).
export const listInjectedWallets = (): InjectedWallet[] => {
  if (typeof window === "undefined") return [];
  const root = (window as unknown as { midnight?: Record<string, unknown> })
    .midnight;
  if (!root || typeof root !== "object") return [];

  const wallets: InjectedWallet[] = [];
  for (const [uuid, entry] of Object.entries(root)) {
    const w = entry as Partial<InitialAPI> & { uuid?: string };
    if (!w || typeof w !== "object" || !w.name) continue;
    wallets.push({
      uuid,
      name: w.name,
      icon: w.icon ?? "",
      apiVersion: w.apiVersion ?? "",
      api: w as InitialAPI,
    });
  }
  return wallets;
};

// Connect to the chosen wallet using the v4 connect(networkId) API.
export const connectWallet = async (
  wallet: InjectedWallet,
  networkId = "testnet"
): Promise<ConnectedWallet> => {
  const connectedApi = await wallet.api.connect(networkId);
  return { connectedApi };
};

// Build the full MidnightProviders set for a browser dApp using the v4 API.
export const createBrowserProviders = async (
  connected: ConnectedWallet
): Promise<NotebookProviders> => {
  const { connectedApi } = connected;

  const config = await connectedApi.getConfiguration();
  // Register the network ID globally so midnight-js SDK internals can read it.
  setNetworkId(config.networkId);
  const shieldedAddresses = await connectedApi.getShieldedAddresses();

  const keyMaterialProvider = new FetchZkConfigProvider<NotebookCircuitKeys>(
    window.location.origin,
    fetch.bind(window)
  );

  return {
    privateStateProvider: inMemoryPrivateStateProvider(),
    publicDataProvider: indexerPublicDataProvider(
      config.indexerUri,
      config.indexerWsUri
    ),
    zkConfigProvider: keyMaterialProvider,
    // Prefer the wallet's built-in proving provider (e.g. 1AM Proof Station).
    // Fall back to an HTTP proof server if the wallet config includes one.
    // Throw if neither is available so the error is explicit rather than silent.
    proofProvider: await (async () => {
      if (typeof connectedApi.getProvingProvider === "function") {
        // getProvingProvider returns a ProvingProvider (circuit-level check/prove).
        // The SDK's proofProvider slot expects a ProofProvider (proveTx). Wrap it.
        const provingProvider = await connectedApi.getProvingProvider(
          keyMaterialProvider
        );
        return createProofProvider(
          provingProvider as Parameters<typeof createProofProvider>[0]
        );
      }
      if (config.proverServerUri) {
        return httpClientProofProvider(
          config.proverServerUri,
          keyMaterialProvider
        );
      }
      throw new Error(
        "No proof provider available: the wallet does not expose a proving service " +
          "and no proverServerUri was found in the wallet configuration. " +
          "Run a local Midnight proof server on http://localhost:6300 and ensure " +
          "your wallet's configuration includes its address."
      );
    })(),
    walletProvider: {
      getCoinPublicKey(): string {
        return shieldedAddresses.shieldedCoinPublicKey;
      },
      getEncryptionPublicKey(): string {
        return shieldedAddresses.shieldedEncryptionPublicKey;
      },
      balanceTx: async (
        tx: UnboundTransaction,
        _ttl?: Date
      ): Promise<FinalizedTransaction> => {
        const serializedTx = toHex(tx.serialize());
        const received = await connectedApi.balanceUnsealedTransaction(
          serializedTx
        );
        return Transaction.deserialize<SignatureEnabled, Proof, Binding>(
          "signature",
          "proof",
          "binding",
          fromHex(received.tx)
        );
      },
    },
    midnightProvider: {
      submitTx: async (tx: FinalizedTransaction): Promise<TransactionId> => {
        await connectedApi.submitTransaction(toHex(tx.serialize()));
        const ids = tx.identifiers();
        return ids[0];
      },
    },
  } satisfies NotebookProviders;
};
Enter fullscreen mode Exit fullscreen mode

Provider roles at a glance:

Provider What it does
privateStateProvider Holds the owner secret key in memory during the session
publicDataProvider Reads on-chain state and streams updates over WebSocket
zkConfigProvider Fetches .verifier and .bzkir key files from the Vite origin
proofProvider Generates ZK proofs; wraps the wallet's ProvingProvider with createProofProvider
walletProvider Balances (adds coin inputs) and deserializes signed transactions
midnightProvider Submits finalized transactions and returns the transaction ID

⚠️WARNING
getProvingProvider() returns a ProvingProvider (circuit-level interface). The SDK's proofProvider slot needs a ProofProvider (transaction-level, with proveTx). Using as unknown as to cast hides the mismatch and produces "proveTx is not a function" at runtime. Always use createProofProvider() to convert between the two.


Step 10: Build the notebook API

Create api/src/notebook-api.ts. This module wraps deployContract and findDeployedContract and exposes the four operations the frontend needs:

import type { ContractAddress } from "@midnight-ntwrk/compact-runtime";
import {
  deployContract,
  findDeployedContract,
} from "@midnight-ntwrk/midnight-js-contracts";
import {
  CompiledNotebookContract,
  notebookLedger,
  createNotebookPrivateState,
  generateRandomSecretKey,
} from "@notebook/contract";
import type { NotebookPrivateState } from "@notebook/contract";
import { type Observable, map } from "rxjs";

import {
  NotebookPrivateStateId,
  type DeployedOrFoundNotebook,
  type NotebookAPI,
  type NotebookDerivedState,
  type NotebookProviders,
} from "./common-types.js";

const toHex = (bytes: Uint8Array): string =>
  Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");

const fromHex = (hex: string): Uint8Array => {
  const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
  if (clean.length !== 64) {
    throw new Error(
      `Expected 32 byte hex string, got ${clean.length / 2} bytes`
    );
  }
  const out = new Uint8Array(32);
  for (let i = 0; i < 32; i += 1) {
    out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
  }
  return out;
};

const buildDerivedState = (ledgerState: {
  owner_commitment: Uint8Array;
  notes: { [Symbol.iterator](): IterableIterator<[Uint8Array, Uint8Array]> };
  note_count: bigint;
}): NotebookDerivedState => {
  const notes = new Map<string, string>();
  for (const [id, commitment] of ledgerState.notes) {
    notes.set(toHex(id), toHex(commitment));
  }
  return {
    ownerCommitment: ledgerState.owner_commitment,
    notes,
    noteCount: ledgerState.note_count,
  };
};

export const deployNotebook = async (
  providers: NotebookProviders,
  secretKey: Uint8Array = generateRandomSecretKey()
): Promise<{ api: NotebookAPI; secretKey: Uint8Array }> => {
  const deployed: DeployedOrFoundNotebook = await deployContract(providers, {
    compiledContract: CompiledNotebookContract,
    privateStateId: NotebookPrivateStateId,
    initialPrivateState: createNotebookPrivateState(secretKey),
  });
  const api = buildAPI(deployed, providers);
  return { api, secretKey };
};

export const joinNotebook = async (
  providers: NotebookProviders,
  contractAddress: ContractAddress,
  secretKey: Uint8Array
): Promise<NotebookAPI> => {
  const found: DeployedOrFoundNotebook = await findDeployedContract(providers, {
    contractAddress,
    compiledContract: CompiledNotebookContract,
    privateStateId: NotebookPrivateStateId,
    initialPrivateState: createNotebookPrivateState(secretKey),
  });
  return buildAPI(found, providers);
};

const buildAPI = (
  deployed: DeployedOrFoundNotebook,
  providers: NotebookProviders
): NotebookAPI => {
  const deployedContractAddress = deployed.deployTxData.public.contractAddress;

  const state$: Observable<NotebookDerivedState> = providers.publicDataProvider
    .contractStateObservable(deployedContractAddress, { type: "latest" })
    .pipe(
      map((contractState) => {
        const ledgerState = notebookLedger(contractState.data) as any;
        return buildDerivedState(ledgerState);
      })
    );

  const postNote = async (contentHash: Uint8Array): Promise<Uint8Array> => {
    const txData = await (deployed as any).callTx.post_note(contentHash);
    return txData.private.result as Uint8Array;
  };

  const deleteNote = async (noteId: Uint8Array): Promise<void> => {
    await (deployed as any).callTx.delete_note(noteId);
  };

  return {
    deployedContractAddress,
    state$,
    postNote,
    deleteNote,
  };
};

export { toHex, fromHex };
Enter fullscreen mode Exit fullscreen mode

How deployContract works: It builds the deploy transaction, calls the ZK prover to generate a proof for the constructor circuit, sends the balanced transaction to the wallet to add coin inputs, submits it to the network, and resolves once it is confirmed. initialPrivateState carries the secret key into the in-memory private state store so the witness functions can read it during each circuit call.

How findDeployedContract works: It locates a contract that was deployed in a previous session by querying the indexer for the contract's current state. It does not redeploy; it simply reconnects and hydrates the local private state with the provided secret key. This is how the frontend reconnects to the same notebook after a page reload.

How the state observable works: contractStateObservable returns an RxJS Observable that emits whenever the on-chain contract state changes. The map operator converts the raw bytes into a NotebookDerivedState object with hex-encoded note IDs as map keys. The React context subscribes to this observable and re-renders on every emission. The subscription is torn down and re-created whenever the api object changes (for example, when the user switches to a different notebook), preventing stale subscriptions from leaking memory.

How callTx.post_note works: The SDK's callTx object is a proxy generated from the compiled contract's circuit list. Calling callTx.post_note(contentHash) triggers the full transaction lifecycle: construct the call arguments, run the witness functions to produce the secret key and nonce, generate the ZK proof, balance the transaction with coin inputs from the wallet, and submit. The resolved value includes txData.private.result, which holds the circuit's return value (the assigned note_id as a Uint8Array).

Finally, create api/src/index.ts to re-export everything from the three source files as a single import point:

export * from "./common-types.js";
export * from "./providers.js";
export * from "./notebook-api.js";
Enter fullscreen mode Exit fullscreen mode

Step 11: Set up the SQLite database

Create backend/package.json. helmet and express-rate-limit are production dependencies, not dev dependencies, because they run in the Express process at runtime:

{
  "name": "@notebook/backend",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "main": "dist/server.js",
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "dev": "tsx watch src/server.ts",
    "start": "node dist/server.js"
  },
  "dependencies": {
    "better-sqlite3": "^11.3.0",
    "cors": "^2.8.5",
    "express": "^4.21.0",
    "express-rate-limit": "^8.3.2",
    "helmet": "^8.1.0"
  },
  "devDependencies": {
    "@types/better-sqlite3": "^7.6.11",
    "@types/cors": "^2.8.17",
    "@types/express": "^5.0.0",
    "@types/node": "^22.0.0",
    "tsx": "^4.19.0",
    "typescript": "^5.6.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Create backend/tsconfig.json. The types: ["node"] entry pulls in Node.js built-in type declarations so process, Buffer, and import.meta resolve correctly:

{
  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "lib": ["ES2022"],
    "types": ["node"]
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}
Enter fullscreen mode Exit fullscreen mode

Create backend/src/db.ts:

import Database, { type Database as DatabaseInstance } from "better-sqlite3";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";

// Single SQLite file shared by all routes. Keyed by (contractAddress, commitment)
// so one backend instance can serve multiple deployed notebooks without collisions.
const DB_PATH = process.env.NOTEBOOK_DB_PATH ?? "./data/notebook.sqlite";

mkdirSync(dirname(DB_PATH), { recursive: true });

export const db: DatabaseInstance = new Database(DB_PATH);
db.pragma("journal_mode = WAL");

db.exec(`
  CREATE TABLE IF NOT EXISTS notes (
    contract_address TEXT NOT NULL,
    commitment TEXT NOT NULL,
    ciphertext TEXT NOT NULL,
    created_at INTEGER NOT NULL,
    PRIMARY KEY (contract_address, commitment)
  );
`);

export type NoteRow = {
  contract_address: string;
  commitment: string;
  ciphertext: string;
  created_at: number;
};
Enter fullscreen mode Exit fullscreen mode

The primary key is (contract_address, commitment). Using the contract address as part of the key means a single backend instance can serve multiple deployed notebooks without any row collisions. WAL journal mode allows concurrent reads while a write is in progress.


Step 12: Write the backend routes

Create backend/src/routes/notes.ts:

import { createHash } from "node:crypto";
import {
  Router,
  type Request,
  type Response,
  type RequestHandler,
} from "express";
import { db, type NoteRow } from "../db.js";

export const notesRouter: Router = Router();

// Basic hex validation. Commitments and ids are 32 byte values encoded as 64
// lowercase hex characters. Contract addresses are opaque strings so we only
// cap their length.
const HEX32 = /^[0-9a-f]{64}$/;
const MAX_ADDR_LEN = 128;
const MAX_CIPHERTEXT_BYTES = 128 * 1024; // 128 KB per note body is plenty.

const isHex32 = (s: unknown): s is string =>
  typeof s === "string" && HEX32.test(s);
const isAddress = (s: unknown): s is string =>
  typeof s === "string" && s.length > 0 && s.length <= MAX_ADDR_LEN;

// Compute SHA-256 of the raw ciphertext bytes and return lowercase hex. This
// lets clients supply any deterministic commitment scheme they want; we just
// echo what we stored so the UI can confirm the round trip.
const sha256Hex = (input: string): string =>
  createHash("sha256").update(input, "utf8").digest("hex");

// POST /api/notes
// body: { contractAddress, commitment, ciphertext }
const postNote: RequestHandler = (req: Request, res: Response) => {
  const { contractAddress, commitment, ciphertext } = req.body ?? {};

  if (!isAddress(contractAddress)) {
    res
      .status(400)
      .json({ error: "contractAddress must be a non-empty string" });
    return;
  }
  if (!isHex32(commitment)) {
    res
      .status(400)
      .json({ error: "commitment must be 64 lowercase hex chars" });
    return;
  }
  if (typeof ciphertext !== "string" || ciphertext.length === 0) {
    res.status(400).json({ error: "ciphertext must be a non-empty string" });
    return;
  }
  if (Buffer.byteLength(ciphertext, "utf8") > MAX_CIPHERTEXT_BYTES) {
    res.status(413).json({ error: "ciphertext too large" });
    return;
  }

  const serverDigest = sha256Hex(ciphertext);

  if (serverDigest !== commitment) {
    res.status(400).json({
      error: "commitment does not match SHA-256 of ciphertext",
    });
    return;
  }

  // Content-addressed storage: if the same (contractAddress, commitment) key
  // already exists the ciphertext is identical by construction (same hash), so
  // we skip the write rather than replacing the existing row.
  const info = db
    .prepare(
      `INSERT OR IGNORE INTO notes (contract_address, commitment, ciphertext, created_at)
     VALUES (?, ?, ?, ?)`
    )
    .run(contractAddress, commitment, ciphertext, Date.now());

  res.status(info.changes === 0 ? 200 : 201).json({
    contractAddress,
    commitment,
    serverDigest,
    digestMatches: true,
  });
};

// GET /api/notes/:contractAddress/:commitment
const getNote: RequestHandler = (req: Request, res: Response) => {
  const { contractAddress, commitment } = req.params;
  if (!isAddress(contractAddress) || !isHex32(commitment)) {
    res.status(400).json({ error: "invalid params" });
    return;
  }

  const row = db
    .prepare(
      `SELECT contract_address, commitment, ciphertext, created_at
       FROM notes WHERE contract_address = ? AND commitment = ?`
    )
    .get(contractAddress, commitment) as NoteRow | undefined;

  if (!row) {
    res.status(404).json({ error: "not found" });
    return;
  }

  res.json({
    contractAddress: row.contract_address,
    commitment: row.commitment,
    ciphertext: row.ciphertext,
    createdAt: row.created_at,
  });
};

// DELETE /api/notes/:contractAddress/:commitment
const deleteNote: RequestHandler = (req: Request, res: Response) => {
  const { contractAddress, commitment } = req.params;
  if (!isAddress(contractAddress) || !isHex32(commitment)) {
    res.status(400).json({ error: "invalid params" });
    return;
  }

  const info = db
    .prepare(`DELETE FROM notes WHERE contract_address = ? AND commitment = ?`)
    .run(contractAddress, commitment);

  if (info.changes === 0) {
    res.status(404).json({ error: "not found" });
    return;
  }
  res.status(204).end();
};

notesRouter.post("/", postNote);
notesRouter.get("/:contractAddress/:commitment", getNote);
notesRouter.delete("/:contractAddress/:commitment", deleteNote);
Enter fullscreen mode Exit fullscreen mode

The POST handler enforces commitment integrity on the server side. It recomputes SHA-256(ciphertext) and compares the result to the client-supplied commitment. If they do not match, it returns 400 and refuses to store anything. This prevents a scenario where an attacker who knows a valid on-chain commitment tries to substitute different content: the server rejects the write because the hash of the submitted body will not equal the real commitment.

The INSERT OR IGNORE statement replaces the earlier INSERT OR REPLACE. Content-addressed storage guarantees that the same (contractAddress, commitment) pair always maps to the same bytes (same hash means same content). If the row already exists, the write is skipped silently and the response returns 200. If it is new, the row is inserted and the response returns 201. There is no case where replacing the existing data makes sense.

Notice that the backend does not perform user authentication. There is no JWT, no API key, and no session. Ownership is already enforced on-chain: only the owner's ZK proof can insert a note into the notes map. The backend is a content-addressed store, not an authority. The commitment integrity check above is what keeps stored content honest.


Step 13: Write the Express server

Create backend/src/server.ts:

import cors from "cors";
import express from "express";
import helmet from "helmet";
import { rateLimit } from "express-rate-limit";
import { notesRouter } from "./routes/notes.js";

const app = express();

// Security headers: X-Content-Type-Options, X-Frame-Options, HSTS, etc.
app.use(helmet());

// Allow the Vite dev origin by default. In production, set BACKEND_CORS_ORIGIN
// to the deployed frontend origin (or a comma separated list).
const corsOrigin = (process.env.BACKEND_CORS_ORIGIN ?? "http://localhost:3000")
  .split(",")
  .map((s) => s.trim())
  .filter(Boolean);

app.use(cors({ origin: corsOrigin }));
app.use(express.json({ limit: "256kb" }));

// Rate limit: 120 requests per 15 minutes per IP across all endpoints.
// POST and DELETE are also limited individually below.
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 120,
  standardHeaders: "draft-8",
  legacyHeaders: false,
});
app.use(globalLimiter);

// Stricter limit on write operations (POST, DELETE) to slow down abuse.
const writeLimiter = rateLimit({
  windowMs: 60 * 1000,
  limit: 20,
  standardHeaders: "draft-8",
  legacyHeaders: false,
});

app.get("/healthz", (_req, res) => {
  res.json({ ok: true });
});

// Apply the write limiter before routing POST and DELETE inside the notes router.
app.post("/api/notes", writeLimiter);
app.delete("/api/notes/*path", writeLimiter);
app.use("/api/notes", notesRouter);

const PORT = Number.parseInt(process.env.PORT ?? "3001", 10);
app.listen(PORT, () => {
  console.log(`Notebook backend listening on http://localhost:${PORT}`);
  console.log(`CORS allowed origins: ${corsOrigin.join(", ")}`);
});
Enter fullscreen mode Exit fullscreen mode

helmet() sets a handful of HTTP security headers in one call: X-Content-Type-Options: nosniff (prevents MIME sniffing), X-Frame-Options: DENY (prevents clickjacking), Strict-Transport-Security (HSTS for production), and several others. Adding it takes one line and closes a class of browser-level attacks with no application logic changes.

The two rate limiters operate at different scopes. The global limiter of 120 requests per 15 minutes per IP applies to every route, including the health check. The write limiter of 20 requests per minute per IP applies only to POST and DELETE, which are the operations that trigger on-chain transactions. These limits are generous for a single user but sufficient to slow down anyone trying to spam the backend. The write limiter is registered directly on app.post and app.delete before the router is mounted, so it runs before the route handler regardless of which path inside the router is matched.

Start the backend in a dedicated terminal:

npm run dev:backend
Enter fullscreen mode Exit fullscreen mode

You should see Notebook backend listening on http://localhost:3001. The server uses tsx watch so it reloads automatically on file changes.


Step 14: Write the frontend crypto helpers

Create frontend/package.json. The frontend depends on both @notebook/api and @notebook/contract (using "*" to always resolve to the local workspace version), as well as the full set of Midnight SDK packages that Vite needs to bundle for the browser:

{
  "name": "@notebook/frontend",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -p tsconfig.json && vite build",
    "preview": "vite preview --port 3000"
  },
  "dependencies": {
    "@notebook/api": "*",
    "@notebook/contract": "*",
    "@midnight-ntwrk/compact-js": "^2.5.0",
    "@midnight-ntwrk/compact-runtime": "^0.15.0",
    "@midnight-ntwrk/dapp-connector-api": "^4.0.1",
    "@midnight-ntwrk/ledger-v8": "^8.0.3",
    "@midnight-ntwrk/midnight-js-contracts": "^4.0.4",
    "@midnight-ntwrk/midnight-js-fetch-zk-config-provider": "^4.0.4",
    "@midnight-ntwrk/midnight-js-http-client-proof-provider": "^4.0.4",
    "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "^4.0.4",
    "@midnight-ntwrk/midnight-js-level-private-state-provider": "^4.0.4",
    "@midnight-ntwrk/midnight-js-network-id": "^4.0.4",
    "@midnight-ntwrk/midnight-js-types": "^4.0.4",
    "@midnight-ntwrk/midnight-js-utils": "^4.0.4",
    "@midnight-ntwrk/wallet-sdk-address-format": "^3.0.1",
    "events": "^3.3.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "rxjs": "^7.8.1",
    "semver": "^7.6.3"
  },
  "devDependencies": {
    "@types/react": "^18.3.10",
    "@types/react-dom": "^18.3.0",
    "@types/semver": "^7.5.8",
    "@vitejs/plugin-react": "^6.0.1",
    "typescript": "^5.6.0",
    "vite": "^8.0.0",
    "vite-plugin-node-polyfills": "^0.26.0",
    "vite-plugin-top-level-await": "^1.6.0",
    "vite-plugin-wasm": "^3.6.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Create frontend/tsconfig.json. The jsx: "react-jsx" option enables the React 18 automatic JSX transform so you do not need to import React in every file. noEmit: true means TypeScript only type-checks; Vite handles the actual compilation and bundling:

{
  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "jsx": "react-jsx",
    "types": ["vite/client"],
    "noEmit": true
  },
  "include": ["src/**/*.ts", "src/**/*.tsx"],
  "exclude": ["node_modules", "dist"]
}
Enter fullscreen mode Exit fullscreen mode

Create frontend/src/lib/crypto.ts. These small utilities handle SHA-256 hashing, hex encoding, and per-browser secret key persistence:

// Small helpers for the browser side. Note that the on-chain content_hash
// must match what the contract expects (32 bytes). We commit to the raw
// ciphertext with SHA-256, which the backend also recomputes so the UI can
// verify the round trip.

export const sha256Bytes = async (s: string): Promise<Uint8Array> => {
  const enc = new TextEncoder();
  const buf = await crypto.subtle.digest("SHA-256", enc.encode(s));
  return new Uint8Array(buf);
};

export const toHex = (bytes: Uint8Array): string =>
  Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");

export const fromHex = (hex: string): Uint8Array => {
  const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
  if (clean.length % 2 !== 0) throw new Error("odd length hex");
  const out = new Uint8Array(clean.length / 2);
  for (let i = 0; i < out.length; i += 1) {
    out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
  }
  return out;
};

// Generate 32 random bytes using the browser RNG. Used for the owner secret
// key. We persist the result in IndexedDB via the private state provider, so
// the user does not need to re-enter it across sessions.
export const randomBytes = (len = 32): Uint8Array => {
  const buf = new Uint8Array(len);
  crypto.getRandomValues(buf);
  return buf;
};

// Very small localStorage backed store for the per-browser owner secret key.
// This is not a secure vault: it's the simplest thing that survives reloads so
// the tutorial reader can reconnect to the same notebook.
const SECRET_KEY_STORAGE_KEY = "notebook.ownerSecretKey";

export const loadOrCreateSecretKey = (): Uint8Array => {
  const existing = localStorage.getItem(SECRET_KEY_STORAGE_KEY);
  if (existing) return fromHex(existing);
  const fresh = randomBytes(32);
  localStorage.setItem(SECRET_KEY_STORAGE_KEY, toHex(fresh));
  return fresh;
};

export const clearSecretKey = (): void => {
  localStorage.removeItem(SECRET_KEY_STORAGE_KEY);
};
Enter fullscreen mode Exit fullscreen mode

⚠️ WARNING
localStorage is convenient for a tutorial, but is not a secure vault. Anyone with access to the browser developer tools can read the key. For production, derive the key from the wallet's signing material or use a dedicated key management solution.


Step 15: Write the backend client

Create frontend/src/lib/backend-client.ts. This thin wrapper handles all HTTP calls to the Express backend:

// Thin wrapper around the off-chain backend. Note bodies live here; the chain
// only ever sees the commitment hash.

export type StoredNote = {
  contractAddress: string;
  commitment: string;
  ciphertext: string;
  createdAt: number;
};

const BASE = "/api/notes";

export const backendClient = {
  async putNote(
    contractAddress: string,
    commitment: string,
    ciphertext: string
  ) {
    const res = await fetch(BASE, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ contractAddress, commitment, ciphertext }),
    });
    if (!res.ok) throw new Error(`backend putNote failed: ${res.status}`);
    return (await res.json()) as {
      contractAddress: string;
      commitment: string;
      serverDigest: string;
      digestMatches: boolean;
    };
  },

  async getNote(
    contractAddress: string,
    commitment: string
  ): Promise<StoredNote | null> {
    const res = await fetch(
      `${BASE}/${encodeURIComponent(contractAddress)}/${encodeURIComponent(
        commitment
      )}`
    );
    if (res.status === 404) return null;
    if (!res.ok) throw new Error(`backend getNote failed: ${res.status}`);
    return (await res.json()) as StoredNote;
  },

  async deleteNote(contractAddress: string, commitment: string) {
    const res = await fetch(
      `${BASE}/${encodeURIComponent(contractAddress)}/${encodeURIComponent(
        commitment
      )}`,
      { method: "DELETE" }
    );
    if (res.status !== 204 && res.status !== 404) {
      throw new Error(`backend deleteNote failed: ${res.status}`);
    }
  },
};
Enter fullscreen mode Exit fullscreen mode

The /api/notes base path works in development because vite.config.ts proxies all /api requests to http://localhost:3001.


Step 16: Write the wallet context

Create frontend/src/contexts/WalletContext.tsx. This context discovers injected wallets, filters to v4-compatible ones, and manages the connection lifecycle:

import React, {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useState,
} from "react";
import semver from "semver";
import {
  listInjectedWallets,
  connectWallet,
  createBrowserProviders,
  type ConnectedWallet,
  type InjectedWallet,
  type NotebookProviders,
} from "@notebook/api";

// Only wallets that implement the v4 connector API are supported.
const COMPATIBLE_VERSION = "4.x";

// Read from VITE_NETWORK_ID env var. Defaults to "preprod" for the 1AM wallet.
const NETWORK_ID: string =
  (import.meta.env.VITE_NETWORK_ID as string | undefined) ?? "preprod";

type WalletState = {
  available: InjectedWallet[];
  connected: ConnectedWallet | null;
  providers: NotebookProviders | null;
  coinPublicKey: string | null;
  status: "idle" | "connecting" | "connected" | "error";
  error: string | null;
};

type WalletContextValue = WalletState & {
  refresh: () => void;
  connect: (wallet: InjectedWallet) => Promise<void>;
};

const WalletContext = createContext<WalletContextValue | null>(null);

export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({
  children,
}) => {
  const [state, setState] = useState<WalletState>({
    available: [],
    connected: null,
    providers: null,
    coinPublicKey: null,
    status: "idle",
    error: null,
  });

  // Filter injected wallets to only show v4-compatible ones.
  const getCompatible = (): InjectedWallet[] =>
    listInjectedWallets().filter(
      (w) => w.apiVersion && semver.satisfies(w.apiVersion, COMPATIBLE_VERSION)
    );

  const refresh = useCallback(() => {
    setState((prev) => ({ ...prev, available: getCompatible() }));
  }, []);

  // Wallet extensions inject window.midnight asynchronously. Poll until a
  // compatible wallet appears or until 20 attempts have elapsed.
  useEffect(() => {
    let attempts = 0;
    const timer = setInterval(() => {
      const wallets = getCompatible();
      setState((prev) => ({ ...prev, available: wallets }));
      attempts += 1;
      if (wallets.length > 0 || attempts > 20) clearInterval(timer);
    }, 250);
    return () => clearInterval(timer);
  }, []);

  const connect = useCallback(async (wallet: InjectedWallet) => {
    setState((prev) => ({ ...prev, status: "connecting", error: null }));
    try {
      const connected = await connectWallet(wallet, NETWORK_ID);
      const providers = await createBrowserProviders(connected);
      const shieldedAddresses =
        await connected.connectedApi.getShieldedAddresses();
      setState({
        available: getCompatible(),
        connected,
        providers,
        coinPublicKey: shieldedAddresses.shieldedCoinPublicKey,
        status: "connected",
        error: null,
      });
    } catch (err) {
      const raw = err instanceof Error ? err.message : String(err);
      const isShutdown =
        raw.includes("shutdown") ||
        raw.includes("Could not establish connection") ||
        raw.includes("Receiving end does not exist");
      setState((prev) => ({
        ...prev,
        status: "error",
        error: isShutdown
          ? "Wallet extension became unresponsive. Open the wallet popup to wake it, then click Connect again."
          : raw,
      }));
    }
  }, []);

  const value = useMemo<WalletContextValue>(
    () => ({ ...state, refresh, connect }),
    [state, refresh, connect]
  );

  return (
    <WalletContext.Provider value={value}>{children}</WalletContext.Provider>
  );
};

export const useWallet = (): WalletContextValue => {
  const ctx = useContext(WalletContext);
  if (!ctx) throw new Error("useWallet must be used inside <WalletProvider>");
  return ctx;
};
Enter fullscreen mode Exit fullscreen mode

The polling loop runs every 250 ms for up to 5 seconds (20 attempts). This handles the race between React mounting and the wallet extension injecting window.midnight. The COMPATIBLE_VERSION = "4.x" semver filter ensures only wallets that implement the v4 connector API appear in the list.

The v4 connector API uses a multi-wallet discovery pattern where window.midnight is a UUID-keyed object rather than a single named entry. Any compliant wallet is discovered automatically without wallet-specific code. The UUID is the wallet's unique identifier and changes per install; using it as a React key prevents stale renders when multiple wallets are present.

The connect function intentionally catches the class of errors produced when the wallet extension becomes unresponsive. These errors ("Receiving end does not exist", "Could not establish connection") happen when the wallet popup closes while the page is still awaiting a response from the extension's message port. The user-facing message tells the person to open the wallet popup before retrying, which re-establishes the message port.


Step 17: Write the notebook context

Create frontend/src/contexts/NotebookContext.tsx. This context wraps the API layer with React state and handles deploy, join, post, and delete operations:

import React, {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import {
  deployNotebook,
  joinNotebook,
  type NotebookAPI,
  type NotebookDerivedState,
} from "@notebook/api";
import { useWallet } from "./WalletContext.js";
import {
  fromHex,
  loadOrCreateSecretKey,
  sha256Bytes,
  toHex,
} from "../lib/crypto.js";
import { backendClient } from "../lib/backend-client.js";

type Status = "idle" | "busy" | "ready" | "error";

type NotebookContextValue = {
  api: NotebookAPI | null;
  derived: NotebookDerivedState | null;
  status: Status;
  error: string | null;
  deploy: () => Promise<void>;
  join: (address: string) => Promise<void>;
  postNote: (body: string) => Promise<void>;
  deleteNote: (noteId: string) => Promise<void>;
  disconnect: () => void;
};

const NotebookContext = createContext<NotebookContextValue | null>(null);

const LAST_ADDRESS_KEY = "notebook.lastContractAddress";

export const NotebookProvider: React.FC<{ children: React.ReactNode }> = ({
  children,
}) => {
  const { providers, status: walletStatus } = useWallet();
  const [api, setApi] = useState<NotebookAPI | null>(null);
  const [derived, setDerived] = useState<NotebookDerivedState | null>(null);
  const [status, setStatus] = useState<Status>("idle");
  const [error, setError] = useState<string | null>(null);
  const subscriptionRef = useRef<{ unsubscribe: () => void } | null>(null);

  // Teardown the state subscription when the API is replaced or on unmount.
  useEffect(() => {
    subscriptionRef.current?.unsubscribe();
    subscriptionRef.current = null;
    if (!api) {
      setDerived(null);
      return;
    }
    const sub = api.state$.subscribe({
      next: (s) => setDerived(s),
      error: (e) => setError(e instanceof Error ? e.message : String(e)),
    });
    subscriptionRef.current = sub;
    return () => sub.unsubscribe();
  }, [api]);

  const deploy = useCallback(async () => {
    if (!providers) {
      setError("Connect a wallet first");
      return;
    }
    setStatus("busy");
    setError(null);
    try {
      const secretKey = loadOrCreateSecretKey();
      const { api: fresh } = await deployNotebook(providers, secretKey);
      localStorage.setItem(LAST_ADDRESS_KEY, fresh.deployedContractAddress);
      setApi(fresh);
      setStatus("ready");
    } catch (err) {
      setStatus("error");
      setError(err instanceof Error ? err.message : String(err));
    }
  }, [providers]);

  const join = useCallback(
    async (address: string) => {
      if (!providers) {
        setError("Connect a wallet first");
        return;
      }
      setStatus("busy");
      setError(null);
      try {
        const secretKey = loadOrCreateSecretKey();
        const fresh = await joinNotebook(providers, address, secretKey);
        localStorage.setItem(LAST_ADDRESS_KEY, address);
        setApi(fresh);
        setStatus("ready");
      } catch (err) {
        setStatus("error");
        setError(err instanceof Error ? err.message : String(err));
      }
    },
    [providers]
  );

  // When the wallet becomes ready, try to auto-rejoin the most recently used
  // notebook. This is optional convenience, not a requirement.
  useEffect(() => {
    if (walletStatus !== "connected" || !providers || api) return;
    const saved = localStorage.getItem(LAST_ADDRESS_KEY);
    if (!saved) return;
    void join(saved).catch(() => {
      // Silent: the saved address may belong to a different network or have been
      // removed. The user can still deploy a fresh notebook from the UI.
    });
  }, [walletStatus, providers, api, join]);

  const postNote = useCallback(
    async (body: string) => {
      if (!api) throw new Error("No notebook ready");
      setStatus("busy");
      setError(null);
      try {
        const commitment = await sha256Bytes(body);
        const commitmentHex = toHex(commitment);
        // Store the body off-chain first. If the on-chain call later fails the
        // body is already safe in the backend (no orphaned on-chain note without
        // a retrievable body). The reverse order risks losing the body if the
        // circuit call times out or is rejected after the tx lands.
        await backendClient.putNote(
          api.deployedContractAddress,
          commitmentHex,
          body
        );
        const noteId = await api.postNote(commitment);
        const noteIdHex = toHex(noteId);
        // Patch local state so the note appears without waiting for the next
        // WebSocket emission. Guard against double-counting: if the indexer
        // pushed the update before this line ran, the note is already present.
        setDerived((prev) => {
          if (!prev || prev.notes.has(noteIdHex)) return prev;
          const notes = new Map(prev.notes);
          notes.set(noteIdHex, commitmentHex);
          return { ...prev, notes, noteCount: prev.noteCount + 1n };
        });
        setStatus("ready");
      } catch (err) {
        setStatus("error");
        setError(err instanceof Error ? err.message : String(err));
      }
    },
    [api]
  );

  const deleteNote = useCallback(
    async (noteIdHex: string) => {
      if (!api || !derived) throw new Error("No notebook ready");
      setStatus("busy");
      setError(null);
      try {
        const commitmentHex = derived.notes.get(noteIdHex);
        await api.deleteNote(fromHex(noteIdHex));
        // Patch local state so the note disappears without waiting for the next
        // WebSocket emission. Guard: if the indexer already removed it, skip.
        setDerived((prev) => {
          if (!prev || !prev.notes.has(noteIdHex)) return prev;
          const notes = new Map(prev.notes);
          notes.delete(noteIdHex);
          return { ...prev, notes, noteCount: prev.noteCount - 1n };
        });
        if (commitmentHex) {
          await backendClient.deleteNote(
            api.deployedContractAddress,
            commitmentHex
          );
        }
        setStatus("ready");
      } catch (err) {
        setStatus("error");
        setError(err instanceof Error ? err.message : String(err));
      }
    },
    [api, derived]
  );

  const disconnect = useCallback(() => {
    localStorage.removeItem(LAST_ADDRESS_KEY);
    setApi(null);
    setDerived(null);
    setStatus("idle");
    setError(null);
  }, []);

  const value = useMemo<NotebookContextValue>(
    () => ({
      api,
      derived,
      status,
      error,
      deploy,
      join,
      postNote,
      deleteNote,
      disconnect,
    }),
    [
      api,
      derived,
      status,
      error,
      deploy,
      join,
      postNote,
      deleteNote,
      disconnect,
    ]
  );

  return (
    <NotebookContext.Provider value={value}>
      {children}
    </NotebookContext.Provider>
  );
};

export const useNotebook = (): NotebookContextValue => {
  const ctx = useContext(NotebookContext);
  if (!ctx)
    throw new Error("useNotebook must be used inside <NotebookProvider>");
  return ctx;
};
Enter fullscreen mode Exit fullscreen mode

The backend-first ordering in postNote is critical. The backend call runs before api.postNote(). If the ordering were reversed and the on-chain transaction confirmed before the backend write, you would have a note hash on-chain with no retrievable body. The UI would show "(body not in backend)" and the note would be unreadable. Storing the body first guarantees it is always available by the time the chain state update propagates.

Why postNote and deleteNote patch derived directly. The state$ observable receives updates via WebSocket from the indexer. In practice, the indexer push can arrive with a delay or miss the update entirely during the current session. Without the local patch, a note would not appear in the UI until the next WebSocket emission, which may require a page refresh. Both functions receive enough information at the point the transaction resolves (noteId from postNote, noteIdHex from the caller for deleteNote) to update the local map immediately.

Each patch is idempotent: the setDerived updater checks whether the note is already present in the map (using prev.notes.has(noteIdHex)) before modifying anything. When the indexer push is fast and arrives before the await resolves, the observable already updated derived with the correct state. The guard detects this and returns prev unchanged, preventing a double-count. When the push is slow or missing, the guard condition is false and the patch runs as intended.


Step 18: Write the UI components

The three UI components follow a layered pattern: each one reads from a context hook and renders based on state, with no business logic of its own. All the async work (connecting, deploying, posting) happens in the context hooks. This keeps the components simple and easy to test.

ConnectWallet

ConnectWallet shows a list of available wallets before connection and switches to a confirmation card once connected. It reads from WalletContext and delegates all work to the connect and refresh functions the context exposes. Create frontend/src/components/ConnectWallet.tsx:

import React from "react";
import { useWallet } from "../contexts/WalletContext.js";

// Only allow safe image sources from wallet metadata to prevent javascript: URIs.
const isSafeIconUrl = (url: string): boolean =>
  url.startsWith("https://") || url.startsWith("data:image/");

export const ConnectWallet: React.FC = () => {
  const {
    available,
    connected,
    coinPublicKey,
    status,
    error,
    connect,
    refresh,
  } = useWallet();

  if (connected) {
    return (
      <div className="card">
        <h3>Wallet connected</h3>
        <p className="muted">Coin public key:</p>
        <p className="mono">{coinPublicKey}</p>
      </div>
    );
  }

  return (
    <div className="card">
      <h3>Connect a Midnight wallet</h3>
      {available.length === 0 ? (
        <>
          <p className="muted">
            No injected wallet detected. Install the 1AM wallet and reload this
            page.
          </p>
          <button className="secondary" onClick={refresh}>
            Scan again
          </button>
        </>
      ) : (
        <div>
          {available.map((w) => (
            <div key={w.uuid} className="wallet-row">
              {w.icon && isSafeIconUrl(w.icon) ? (
                <img src={w.icon} alt="" />
              ) : null}
              <div style={{ flex: 1 }}>
                <div>
                  <strong>{w.name}</strong>
                  <span className="muted"> v{w.apiVersion}</span>
                </div>
              </div>
              <button
                onClick={() => connect(w)}
                disabled={status === "connecting"}
              >
                {status === "connecting" ? "Connecting..." : "Connect"}
              </button>
            </div>
          ))}
        </div>
      )}
      {error ? <p className="error">{error}</p> : null}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

DeployJoin

DeployJoin is invisible until the wallet is connected (it returns null when connected is falsy). Once the wallet is ready, it offers two paths: deploy a fresh notebook owned by this browser, or join an existing one by pasting a contract address. After a successful deploy or join, the component switches to a confirmation card showing the contract address and a "Switch notebook" button that clears the state. Create frontend/src/components/DeployJoin.tsx:

import React, { useState } from "react";
import { useNotebook } from "../contexts/NotebookContext.js";
import { useWallet } from "../contexts/WalletContext.js";

export const DeployJoin: React.FC = () => {
  const { connected } = useWallet();
  const { api, status, error, deploy, join, disconnect } = useNotebook();
  const [address, setAddress] = useState("");

  if (!connected) return null;

  if (api) {
    return (
      <div className="card">
        <h3>Notebook ready</h3>
        <p className="muted">Contract address:</p>
        <p className="mono">{api.deployedContractAddress}</p>
        <button className="secondary" onClick={disconnect}>
          Switch notebook
        </button>
      </div>
    );
  }

  return (
    <div className="card">
      <h3>Deploy or join a notebook</h3>
      <p className="muted">
        Deploy a brand new notebook owned by this browser, or paste an existing
        contract address to rejoin one you deployed earlier.
      </p>

      <div style={{ margin: "1rem 0" }}>
        <button onClick={deploy} disabled={status === "busy"}>
          {status === "busy" ? "Deploying..." : "Deploy new notebook"}
        </button>
      </div>

      <div>
        <label htmlFor="addr" className="muted">
          Existing contract address
        </label>
        <input
          id="addr"
          value={address}
          onChange={(e) => setAddress(e.target.value)}
          placeholder="0x..."
        />
        <div style={{ marginTop: "0.5rem" }}>
          <button
            className="secondary"
            disabled={status === "busy" || address.length === 0}
            onClick={() => join(address.trim())}
          >
            Join
          </button>
        </div>
      </div>

      {error ? <p className="error">{error}</p> : null}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

NoteList

NoteList is the most complex component. It subscribes indirectly to the on-chain state through NotebookContext, and whenever derived updates (meaning a new note was posted, or a note was deleted, or the chain confirmed a previous transaction), it fetches the body for every note from the backend. The commitment comparison inside the effect is the end-to-end integrity check: a note that was tampered with in the backend would produce a digest that does not match the on-chain commitment, and the mismatch badge would appear immediately. Create frontend/src/components/NoteList.tsx:

import React, { useEffect, useState } from "react";
import { useNotebook } from "../contexts/NotebookContext.js";
import { backendClient } from "../lib/backend-client.js";

type NoteView = {
  noteId: string;
  commitment: string;
  body: string | null;
  matches: boolean | null;
  createdAt: number | null;
};

export const NoteList: React.FC = () => {
  const { api, derived, status, error, postNote, deleteNote } = useNotebook();
  const [draft, setDraft] = useState("");
  const [notes, setNotes] = useState<NoteView[]>([]);

  // Every time on-chain state refreshes, fetch any missing bodies from the
  // backend. The commitment comparison below proves the body we got back is
  // the one that was committed on-chain.
  useEffect(() => {
    if (!api || !derived) {
      setNotes([]);
      return;
    }
    let cancelled = false;
    (async () => {
      const entries = Array.from(derived.notes.entries());
      const views = await Promise.all(
        entries.map(async ([noteId, commitment]) => {
          try {
            const row = await backendClient.getNote(
              api.deployedContractAddress,
              commitment
            );
            if (!row) {
              return {
                noteId,
                commitment,
                body: null,
                matches: null,
                createdAt: null,
              };
            }
            // Recompute the digest locally to confirm the backend returned the
            // same bytes that are committed on chain.
            const digest = await sha256Hex(row.ciphertext);
            return {
              noteId,
              commitment,
              body: row.ciphertext,
              matches: digest === commitment,
              createdAt: row.createdAt,
            };
          } catch {
            return {
              noteId,
              commitment,
              body: null,
              matches: null,
              createdAt: null,
            };
          }
        })
      );
      // Newest first: notes with no backend row (just locally patched) sort to
      // the top since they were just created.
      views.sort((a, b) => {
        if (a.createdAt === null && b.createdAt === null) return 0;
        if (a.createdAt === null) return -1;
        if (b.createdAt === null) return 1;
        return b.createdAt - a.createdAt;
      });
      if (!cancelled) setNotes(views);
    })();
    return () => {
      cancelled = true;
    };
  }, [api, derived]);

  if (!api || !derived) return null;

  return (
    <div className="card">
      <h3>Notes ({String(derived.noteCount)})</h3>

      <div style={{ margin: "1rem 0" }}>
        <textarea
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          placeholder="Write a private note. Its body stays off-chain; only a commitment hash gets posted."
        />
        <div style={{ marginTop: "0.5rem" }}>
          <button
            disabled={status === "busy" || draft.length === 0}
            onClick={async () => {
              await postNote(draft);
              setDraft("");
            }}
          >
            {status === "busy" ? "Posting..." : "Post note"}
          </button>
        </div>
      </div>

      {notes.length === 0 ? (
        <p className="muted">No notes yet. Post your first one above.</p>
      ) : (
        notes.map((n) => (
          <div className="note" key={n.noteId}>
            <div style={{ flex: 1 }}>
              <div>
                {n.body ?? <span className="muted">(body not in backend)</span>}
              </div>
              <div className="muted mono">
                note_id {n.noteId.slice(0, 16)}...
              </div>
              <div className="muted mono">
                commitment {n.commitment.slice(0, 16)}...
              </div>
              {n.matches === true ? (
                <div className="success">
                  verified: body matches on-chain commitment
                </div>
              ) : n.matches === false ? (
                <div className="error">
                  mismatch: body does not match on-chain commitment
                </div>
              ) : null}
            </div>
            <button
              className="danger"
              disabled={status === "busy"}
              onClick={() => deleteNote(n.noteId)}
            >
              Delete
            </button>
          </div>
        ))
      )}

      {error ? <p className="error">{error}</p> : null}
    </div>
  );
};

const sha256Hex = async (s: string): Promise<string> => {
  const buf = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(s)
  );
  return Array.from(new Uint8Array(buf), (b) =>
    b.toString(16).padStart(2, "0")
  ).join("");
};
Enter fullscreen mode Exit fullscreen mode

NoteList re-fetches note bodies whenever the on-chain state observable emits a new value. For each note it fetches the body from the backend and recomputes the SHA-256 digest locally. When the digest matches the on-chain commitment, it shows "verified: body matches on-chain commitment". This proves the backend returned exactly the bytes that were committed.


Step 19: Wire up the React entry point

Create frontend/src/App.tsx:

import React from "react";
import { ConnectWallet } from "./components/ConnectWallet.js";
import { DeployJoin } from "./components/DeployJoin.js";
import { NoteList } from "./components/NoteList.js";

export const App: React.FC = () => {
  return (
    <main>
      <h1>Private Notebook on Midnight</h1>
      <p className="muted">
        Post notes whose bodies live off-chain while only commitment hashes and
        the owner's derived public key live on the ledger.
      </p>
      <ConnectWallet />
      <DeployJoin />
      <NoteList />
    </main>
  );
};
Enter fullscreen mode Exit fullscreen mode

Create frontend/index.html. Vite uses this as the entry point for the browser. The <div id="root"> is where React mounts, and the <script> tag points Vite at the TypeScript entry point:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Private Notebook on Midnight</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Create frontend/src/styles.css. This stylesheet sets the dark theme, card layout, button colours, and utility classes (muted, mono, error, success) referenced throughout the components:

:root {
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  color-scheme: light dark;
  background: #0d1117;
  color: #e6edf3;
}

body {
  margin: 0;
  min-height: 100vh;
}

#root {
  max-width: 960px;
  margin: 0 auto;
  padding: 2rem 1.5rem 4rem;
}

h1,
h2,
h3 {
  letter-spacing: -0.01em;
}

.card {
  background: #161b22;
  border: 1px solid #30363d;
  border-radius: 10px;
  padding: 1.25rem 1.5rem;
  margin: 1rem 0;
}

button {
  background: #238636;
  color: white;
  border: 0;
  border-radius: 6px;
  padding: 0.55rem 1rem;
  font-weight: 600;
  cursor: pointer;
}
button[disabled] {
  background: #30363d;
  cursor: not-allowed;
}
button.secondary {
  background: #21262d;
}
button.danger {
  background: #8b1f22;
}

input,
textarea {
  background: #0d1117;
  color: #e6edf3;
  border: 1px solid #30363d;
  border-radius: 6px;
  padding: 0.5rem 0.75rem;
  font: inherit;
  width: 100%;
  box-sizing: border-box;
}

textarea {
  min-height: 100px;
  resize: vertical;
}

.note {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 1rem;
  padding: 0.75rem 0;
  border-top: 1px solid #30363d;
}
.note:first-child {
  border-top: 0;
}

.muted {
  color: #8b949e;
  font-size: 0.85rem;
}
.mono {
  font-family: "SF Mono", Menlo, Consolas, monospace;
  font-size: 0.85rem;
}
.error {
  color: #f85149;
}
.success {
  color: #3fb950;
}

.wallet-row {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.5rem 0;
}
.wallet-row img {
  width: 28px;
  height: 28px;
  border-radius: 4px;
}
Enter fullscreen mode Exit fullscreen mode

Create frontend/src/main.tsx:

import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App.js";
import { WalletProvider } from "./contexts/WalletContext.js";
import { NotebookProvider } from "./contexts/NotebookContext.js";
import "./styles.css";

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <WalletProvider>
      <NotebookProvider>
        <App />
      </NotebookProvider>
    </WalletProvider>
  </React.StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

The provider nesting order matters: WalletProvider must be the outer wrapper because NotebookProvider reads from WalletContext to access the providers object. If you reversed the nesting, NotebookProvider would mount before WalletProvider and the useWallet() call inside it would throw immediately.


Step 20: Configure Vite

Create frontend/.env. This file sets the network ID passed to wallet.connect(networkId) at runtime. The 1AM wallet uses preprod for the preprod network:

# Network ID passed to wallet.connect(networkId).
VITE_NETWORK_ID=preprod
Enter fullscreen mode Exit fullscreen mode

Create frontend/vite.config.ts. This configuration serves the ZK key files during development and bundles them into the production build:

import { readFileSync, readdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
import { nodePolyfills } from "vite-plugin-node-polyfills";

const __dirname = dirname(fileURLToPath(import.meta.url));
// Compiled Compact artifact directory produced by `npm run compile:contract`.
const managedDir = resolve(__dirname, "../contract/src/managed/notebook");

export default defineConfig({
  plugins: [
    react(),
    // Provides browser-compatible polyfills for Node.js built-ins (events,
    // buffer, process) that level-based private state provider needs.
    nodePolyfills(),
    wasm(),
    topLevelAwait({
      promiseExportName: "__tla",
      promiseImportName: (i) => `__tla_${i}`,
    }),
    // Serve ZK key material (keys/, zkir/) from the compiled contract artifacts
    // so FetchZkConfigProvider can fetch them at {origin}/keys/{circuit}.verifier
    // and {origin}/zkir/{circuit}.bzkir during both dev and production builds.
    {
      name: "midnight-zk-assets",
      configureServer(server) {
        server.middlewares.use((req, res, next) => {
          const match = (req.url ?? "").match(/^\/(keys|zkir)\/([^?#]+)$/);
          if (match) {
            const [, subdir, filename] = match;
            try {
              const data = readFileSync(resolve(managedDir, subdir, filename));
              res.setHeader("Content-Type", "application/octet-stream");
              res.end(data);
              return;
            } catch {
              // file not found; fall through to next middleware
            }
          }
          next();
        });
      },
      generateBundle() {
        for (const subdir of ["keys", "zkir"]) {
          try {
            for (const file of readdirSync(resolve(managedDir, subdir))) {
              this.emitFile({
                type: "asset",
                fileName: `${subdir}/${file}`,
                source: readFileSync(resolve(managedDir, subdir, file)),
              });
            }
          } catch {
            // compiled artifacts not present yet; skip silently
          }
        }
      },
    },
    // compact-runtime@0.15+ re-exports from onchain-runtime-v3. When the
    // pre-bundler sees that import, keep the module non-external so rolldown
    // can inline it with native top-level-await support instead of failing.
    {
      name: "midnight-wasm-resolver",
      resolveId(source, importer) {
        if (
          source === "@midnight-ntwrk/onchain-runtime-v3" &&
          importer?.includes("@midnight-ntwrk/compact-runtime")
        ) {
          return { id: source, external: false, moduleSideEffects: true };
        }
        return null;
      },
    },
  ],
  server: {
    port: 3000,
    proxy: {
      "/api": {
        target: "http://localhost:3001",
        changeOrigin: true,
      },
    },
    fs: { allow: [".."] },
  },
  optimizeDeps: {
    // Vite 8 uses rolldown for pre-bundling. Declaring native TLA support lets
    // rolldown bundle WASM modules that use top-level await without errors.
    rolldownOptions: {
      platform: "browser",
    },
    // Pre-bundle compact-runtime so its named exports (checkRuntimeVersion,
    // ContractState, etc.) are available via namespace imports in managed code.
    include: ["@midnight-ntwrk/compact-runtime"],
    // Exclude raw WASM packages; they are served directly and handled by the
    // wasm and top-level-await plugins at runtime.
    exclude: [
      "@midnight-ntwrk/onchain-runtime-v3",
      "@midnight-ntwrk/ledger",
      "@midnight-ntwrk/zswap",
    ],
  },
  build: {
    target: "esnext",
    commonjsOptions: {
      ignoreDynamicRequires: true,
      transformMixedEsModules: true,
    },
  },
  resolve: {
    extensions: [".mjs", ".js", ".ts", ".jsx", ".tsx", ".json", ".wasm"],
    mainFields: ["browser", "module", "main"],
  },
});
Enter fullscreen mode Exit fullscreen mode

The midnight-zk-assets plugin has two hooks:

  • configureServer intercepts requests for /keys/* and /zkir/* during development and serves the files directly from the compiled contract directory.
  • generateBundle emits those same files as static assets in the production build so they are available from the deployed origin.

Without this plugin, FetchZkConfigProvider cannot fetch verifier keys and the "Failed to configure verifier key for circuit" error appears at runtime.

The midnight-wasm-resolver plugin handles a specific module resolution quirk in compact-runtime@0.15+. The library re-exports from @midnight-ntwrk/onchain-runtime-v3, which is a native WASM module. Vite's pre-bundler (rolldown in Vite 8) needs to know that this module should be inlined rather than treated as an external dependency, otherwise top-level await inside the WASM module causes a bundling error. The plugin intercepts the import at resolve time and marks it as non-external.

The optimizeDeps section pre-bundles @midnight-ntwrk/compact-runtime and excludes the raw WASM packages. This combination ensures that the named exports from the managed contract code (which uses namespace imports) are available on first page load without a re-bundling step, while the WASM modules themselves are served as separate files and loaded asynchronously by the wasm and topLevelAwait plugins.


Step 21: Run the project end to end

Follow these steps in order.

1. Install all dependencies from the monorepo root. All four workspace package.json files are now in place, so npm can resolve and link everything in one shot:

   npm install
Enter fullscreen mode Exit fullscreen mode

2. Compile the contract. If you followed this tutorial and ran compact compile in Step 6 without changing notebook.compact since, you can skip this. The managed artifacts are already on disk. Only run it if contract/src/managed/ does not exist or you changed the contract:

   npm run compile:contract
Enter fullscreen mode Exit fullscreen mode

3. Build the contract package. This runs tsc and then copy-managed.mjs, which copies the compiled Compact artifacts from contract/src/managed/ into contract/dist/managed/. The @notebook/contract package exports point to dist/, so nothing can import from it until this step runs:

   npm run build -w contract
Enter fullscreen mode Exit fullscreen mode

4. Build the API package. The api imports from @notebook/contract (now in dist/). Compile it so the frontend can resolve its exports:

   npm run build -w api
Enter fullscreen mode Exit fullscreen mode

5. Start the backend in one terminal:

   npm run dev:backend
Enter fullscreen mode Exit fullscreen mode

You should see Notebook backend listening on http://localhost:3001.

backend running

6. Start the frontend in a second terminal:

   npm run dev:frontend
Enter fullscreen mode Exit fullscreen mode

Vite starts on http://localhost:3000.

Frontend running

7. Open Chrome and navigate to http://localhost:3000. Make sure the 1AM wallet extension is installed. You do not need tDUST: ProofStation by 1AM sponsors transaction fees on preprod automatically.

8. Connect the wallet. The Connect Wallet card lists all detected v4-compatible wallets. Click Connect next to the 1AM entry. The wallet extension prompts you to authorize the connection.

Private notebook

Private notebook

9. Deploy a notebook. Click "Deploy new notebook". The wallet initiates proof generation (30 to 60 seconds on first use) and submits the deploy transaction. Once confirmed, the contract address appears in the Notebook ready card.

Private notebook

Private notebook

10. Post a note. Type any text in the textarea and click Post note. The frontend stores the body in the backend first, then calls the on-chain circuit. After the transaction confirms, the note appears with a green "verified" badge.

Private notebook

create Private notebook

11. Reload the page. Connect the wallet again. The context reads the saved contract address from localStorage and auto-rejoins. Your notes are still there.

12. Delete a note. Click Delete. The circuit removes the entry from the on-chain notes map and the backend row is deleted.

⚠️ WARNING
The 1AM wallet syncs the full transaction history the first time it connects to preprod. Wait for the sync indicator to reach 100% before deploying. Transactions submitted during a partial sync may not produce accurate state updates.


Understand the privacy properties

Owner identity: The deployer's secret key never leaves the browser. The on-chain owner_commitment is persistentHash(["notebook:pk:", secretKey]). An observer can read this hash but cannot reverse it to find the key.

Note content: Only SHA-256 commitment hashes reach the chain. An observer sees a list of 32-byte values and a counter. Without the backend, they know notes exist but not what they say.

Authorship: The ZK proof for each transaction proves that the caller's derived public key equals owner_commitment without revealing the secret key. A verifier confirms only the owner could have sent this transaction.

Nonce uniqueness: The fresh_nonce witness generates 32 random bytes per call. Hashing the nonce with the content hash produces a unique note ID even when two notes have identical bodies. This prevents an observer from comparing IDs to infer duplicate content.

Limitations: The backend is not end-to-end encrypted in this tutorial. The server operator can read note bodies. For production, encrypt note bodies client-side before sending them to the backend. The on-chain commitment then binds to the ciphertext rather than the plaintext.


Conclusion

You have built a complete privacy-preserving DApp on Midnight, covering every layer of the stack:

  • A Compact smart contract that enforces ownership with ZK-verified public key derivation and stores only commitment hashes on-chain
  • TypeScript witnesses that supply the secret key and random nonces to ZK circuits at proving time
  • An in-memory private state provider that holds the secret key during a browser session
  • A providers factory that assembles the full Midnight SDK provider set from a connected wallet, including the 1AM Proof Station adapter
  • An Express and SQLite backend that stores note bodies off-chain keyed by commitment, with server-side SHA-256 verification
  • A React frontend with wallet discovery, deploy and join flows, backend-first note posting, and client-side commitment verification

The design principles here carry directly into other privacy-sensitive applications: a medical records DApp, a sealed-bid auction, a private voting system. The pattern is always the same: keep secrets off-chain and in witnesses, put only commitments on-chain, and let ZK proofs enforce the rules.

From here, you can extend this project by encrypting note bodies before sending them to the backend, adding multi-party notebooks where multiple secret keys can authorize writes, or migrating to a decentralized storage backend such as IPFS. The Midnight documentation at docs.midnight.network covers each of these patterns in the bulletin board and DEX reference implementations.

Top comments (0)