Building a Privacy-Preserving Event Ticketing dApp on Midnight Network
midnight

Building a Privacy-Preserving Event Ticketing dApp on Midnight Network

A complete, step-by-step guide to ZK-proof ticketing, from contract to frontend to production.

Mechack Elie (8pro)
Mechack Elie (8pro)
·May 6, 2026·42 min read·12 views
#midnight#ZKPs#dApps#blockchain

When you buy a concert ticket, the venue knows your name. The ticketing platform knows your email, your purchase history, and possibly your date of birth. None of that information is necessary for the only thing that matters: proving you have a valid, unused ticket.

This article walks through building Private Event Tickets, a full-stack decentralised application on Midnight Network that flips that model. Attendees prove they hold a valid ticket using a zero-knowledge proof. No name. No email. No date of birth on-chain. Just a cryptographic guarantee.

By the end, you will understand not just how the code works, but why each piece is designed the way it is.

Table of Contents

  1. What we are building

  2. The technology stack

  3. Project structure

  4. The ZK contract in Compact

  5. The SDK layer

  6. The Express backend

  7. The Next.js frontend

  8. The wallet integration

  9. The full ticket flow, end-to-end

  10. Security decisions explained

  11. Running the project locally

1. What we are building

The application has three roles:

The organiser deploys a smart contract to Midnight. The contract records the event name, capacity, and minimum age. The organiser receives a private caller secret , a random scalar they must keep safe to manage the event later.

The attendee finds the event and claims a ticket. They enter their birth year. Their browser generates a ZK proof that says "this person's age meets the minimum, and I am not disclosing their birth year." The proof is submitted on-chain and a private ticket nonce is saved in the attendee's browser. This nonce is the ticket. It becomes a QR code.

At the door, the organiser scans the QR code. The app calls admit_ticket() on-chain. The contract verifies the ticket exists and has not been used before, then marks it as used. The attendee's browser automatically detects this and shows an ADMITTED stamp.

At every point, the only things stored on the public blockchain are:

  • A hash of the organiser's secret

  • A hash of each ticket nonce

  • A hash of each used ticket nonce

Nobody's real identity or date of birth is ever visible.

2. The technology stack

Layer

Technology

Why

Smart contract

Compact (Midnight)

Compiles to ZK circuits natively

SDK

TypeScript

Typed wrapper around the compiled contract

Backend

Express 4, Prisma, PostgreSQL

Session auth, off-chain metadata, real-time updates

Frontend

Next.js 15, Tailwind CSS v4, framer-motion

App Router, SSR-safe dynamic imports

Auth

Google OAuth + express-session

Simple, widely understood

Wallet

Lace (Midnight DApp Connector API)

Only production Midnight wallet

Real-time

Socket.io (WebSocket only)

Live ADMITTED stamp, no polling

One important constraint shapes the entire codebase: the Midnight SDK uses Node.js readFileSync at module load time, which crashes Next.js during server-side rendering. Every SDK import in the frontend must be a dynamic import() inside an async function, never at the top of a file. This comes up repeatedly throughout the article.

3. Project structure

private-event-tickets/
├── contract/
│   └── event-tickets.compact     ← the ZK smart contract
├── sdk/src/
│   ├── types.ts                  ← shared types and network config
│   ├── providers.ts              ← assembles the Midnight provider bundle
│   ├── contract-api.ts           ← typed class wrapping every circuit
│   └── http-proof-provider.ts    ← proxies ZK proofs to the local Docker server
├── frontend/                     ← Next.js 15 App Router
│   ├── app/                      ← pages and API routes
│   ├── contexts/                 ← WalletContext, AuthContext
│   ├── hooks/                    ← all business logic lives here
│   ├── lib/                      ← api.ts, storage.ts, utils/
│   └── components/               ← pure rendering only
├── backend/src/                  ← Express 4 API
│   ├── config.ts                 ← Zod env validation
│   ├── routes/                   ← auth, events, tickets
│   ├── services/                 ← userService, eventService, ticketService
│   ├── middleware/               ← requireAuth, rateLimiter, errorHandler
│   ├── prisma/schema.prisma      ← database schema
│   └── socket.ts                 ← Socket.io server
└── generated/                    ← compiled contract artefacts (pre-committed)

The separation between sdk/ and frontend/ is deliberate. The SDK knows nothing about React. It is a plain TypeScript class that wraps the compiled contract. This makes it reusable in Node scripts, tests, or future mobile clients.

4. The ZK contract in Compact

Midnight's smart contract language is called Compact. It looks similar to TypeScript but compiles to a zero-knowledge circuit. The key idea: anything marked with disclose() ends up visible on the public ledger. Everything else stays private.

The full contract is in contract/event-tickets.compact.

4.1 Public ledger state

compact
---------------------------------------------------------------------------------------------

pragma language_version >= 0.20;

import CompactStandardLibrary;

export ledger organizer:          Bytes<32>;
export ledger event_name:         Bytes<32>;
export ledger total_tickets:      Uint<32>;
export ledger tickets_issued:     Counter;
export ledger is_active:          Boolean;
export ledger is_cancelled:       Boolean;
export ledger min_age:            Uint<8>;
export ledger ticket_commitments: Set<Bytes<32>>;
export ledger used_tickets:       Set<Bytes<32>>;
export ledger delegates:          Set<Bytes<32>>;

The word ledger means these fields live on-chain. Notice what is not here: no names, no email addresses, no birth dates, no ticket IDs. The organizer field is not the organiser's address — it is persistentHash(organiser_secret). The actual secret never touches the chain.

ticket_commitments is a Set<Bytes<32>>. Each element is persistentHash(ticket_nonce). The nonce is the attendee's private ticket secret. The commitment is what goes on-chain.

4.2 Witnesses

compact
---------------------------------------------------------------------------------------------

witness caller_secret(): Field;
witness ticket_nonce():  Field;
witness birth_year():    Uint<16>;

Witnesses are private inputs to the ZK circuit. The compiler generates a proving system where the circuit can use these values in computations and assertions, but the values themselves are never revealed. The caller's secret, the ticket nonce, and the birth year are all witnesses.

4.3 Creating an event

compact
---------------------------------------------------------------------------------------------

export circuit create_event(
  name    : Bytes<32>,
  total   : Uint<32>,
  age_req : Uint<8>
): [] {
  assert(organizer == default<Bytes<32>>, "Event already initialized");
  organizer     = disclose(persistentHash<Field>(caller_secret()));
  event_name    = disclose(name);
  total_tickets = disclose(total);
  min_age       = disclose(age_req);
  is_active     = disclose(true);
  is_cancelled  = disclose(false);
}

The first line is the one-shot guard. default<Bytes<32>> is all zeros. If organizer is not all zeros, the event has already been initialised, then the assert fails and the circuit aborts. This means the contract can only be initialised once per deployment.

disclose(persistentHash<Field>(caller_secret())) is the key pattern. The organiser's secret enters the circuit as a private witness, gets hashed, and the hash is disclosed to the ledger. If the organiser calls any circuit later, they prove they know the preimage of this hash, but without ever revealing the preimage itself.

4.4 Claiming a ticket (the age proof)

compact
---------------------------------------------------------------------------------------------

export circuit claim_ticket(current_year: Uint<16>): [] {
  assert(!is_cancelled, "Event is cancelled");
  assert(is_active, "Event is not active");
  assert(tickets_issued < total_tickets, "Event is sold out");

  const byear: Uint<16> = birth_year();
  assert(current_year >= byear, "Invalid birth year");
  const age: Uint<16>   = current_year - byear;
  assert(age >= (min_age as Uint<16>), "Age requirement not met");

  const nonce: Field          = ticket_nonce();
  const commitment: Bytes<32> = persistentHash<Field>(nonce);
  ticket_commitments.insert(disclose(commitment));
  tickets_issued += 1;
}

This is where the privacy magic lives. birth_year() is a witness, it comes from the attendee's browser and is never sent anywhere. The circuit computes the age from the birth year and the current year, then asserts it meets the minimum. The proof says: "I ran these computations. The age constraint holds. I'm not telling you the birth year."

Notice the only thing inserted into ticket_commitments is the hash of the nonce; not the nonce itself. The raw nonce stays in the attendee's localStorage.

Why Uint<16> for years instead of a full Unix timestamp? Two reasons. First, Compact's integer types have fixed sizes, and year-level precision is all you need for an age gate. Second, large timestamp arithmetic at the ZK circuit level is expensive in proving time. Year differences stay in a comfortable range for Uint<16>.

4.5 Admitting at the venue

compact
---------------------------------------------------------------------------------------------

export circuit admit_ticket(): [] {
  const caller_h: Bytes<32>   = persistentHash<Field>(caller_secret());
  assert(disclose(caller_h == organizer) || delegates.member(disclose(caller_h)), "Not authorized");
  const nonce: Field          = ticket_nonce();
  const commitment: Bytes<32> = persistentHash<Field>(nonce);
  assert(ticket_commitments.member(disclose(commitment)), "Ticket not found");
  assert(!used_tickets.member(disclose(commitment)), "Ticket already used");
  used_tickets.insert(disclose(commitment));
}

The circuit checks two things in sequence. First, the caller must be either the organiser or a registered delegate; proven without revealing the secret. Second, the ticket nonce hashes to a commitment that exists in ticket_commitments and does not yet exist in used_tickets. Only if both hold does the circuit succeed and insert the commitment into used_tickets.

Double-admission is impossible by design. Once a commitment is in used_tickets, the second assert fails every time.

4.6 The delegate system

compact
---------------------------------------------------------------------------------------------

export circuit grant_delegate(): [] {
  const caller_h: Bytes<32> = persistentHash<Field>(caller_secret());
  assert(disclose(caller_h == organizer), "Only the organizer can grant delegate access");
  assert(!is_cancelled, "Event is cancelled");
  const d_hash: Bytes<32> = persistentHash<Field>(ticket_nonce());
  delegates.insert(disclose(d_hash));
}

The organiser generates a random scalar (called the delegate secret), passes it through the ticket_nonce() witness, hashes it, and stores only the hash on-chain. The organiser then shares the raw scalar with a co-manager via a secure channel. The co-manager uses it as their caller_secret when calling admission circuits.

This is append-only in v1; there is no revocation circuit yet. The code comments acknowledge this openly.

5. The SDK layer

The SDK is a TypeScript class that sits between the compiled contract and the frontend. It has no React dependencies whatsoever. Its job is to:

  1. Assemble the provider bundle (indexer, proof server, private state storage)

  2. Manage private witnesses (the three scalar values the circuits need)

  3. Expose clean async methods for each circuit

5.1 Network configuration

typescript
// sdk/src/types.ts
---------------------------------------------------------------------------------------------

export const PREPROD_CONFIG = {
  networkId: "preprod" as const,
  indexerUri: "https://indexer.preprod.midnight.network/api/v3/graphql",
  indexerWsUri: "wss://indexer.preprod.midnight.network/api/v3/graphql/ws",
  substrateNodeUri: "wss://rpc.preprod.midnight.network",
  proofServerUri: "http://localhost:6300",
};

This is the only place where network endpoints are defined. The frontend never hardcodes URLs, it imports this constant.

5.2 Building the provider bundle

The Midnight SDK requires a bundle of four providers before you can do anything with a contract. providers.ts assembles this bundle from a connected Lace wallet.

typescript
// sdk/src/providers.ts (condensed)
---------------------------------------------------------------------------------------------

export async function createEventTicketProviders(
  wallet: WalletConnectedAPI,
  config: NetworkConfig,
): Promise<MidnightProviders> {
  setNetworkId(config.networkId);

  // 1. ZK artefact provider — fetches .prover and .verifier files
  const zkConfigProvider = new FetchZkConfigProvider<string>(
    `${window.location.origin}/contracts/event-tickets`,
    (url, init) => window.fetch(url, init),
  );

  // 2. Resolve wallet key material synchronously before it's needed
  const { shieldedCoinPublicKey, shieldedEncryptionPublicKey, shieldedAddress } =
    await wallet.getShieldedAddresses();
  const coinPublicKey = parseCoinPublicKeyToHex(shieldedCoinPublicKey, networkId);
  const encPublicKey  = parseEncPublicKeyToHex(shieldedEncryptionPublicKey, networkId);

  // 3. Private state — IndexedDB, scoped to this wallet address
  const privateStateProvider = levelPrivateStateProvider({
    privateStoragePasswordProvider: () => `midnight-${shieldedAddress.slice(0, 32)}`,
    accountId: shieldedAddress,
  });

  // 4. Public data — Midnight indexer over GraphQL
  const publicDataProvider = indexerPublicDataProvider(
    config.indexerUri,
    config.indexerWsUri,
  );

  // 5. Proof provider — wallet-delegated (Lace v4) or local Docker fallback
  let proofProvider;
  if (typeof wallet.getProvingProvider === "function") {
    const proving = await wallet.getProvingProvider(zkConfigProvider.asKeyMaterialProvider());
    proofProvider = createProofProvider(proving);
  } else {
    // Local Docker server proxied through Next.js /api/proof to avoid CORS
    const httpProvider = new HttpProofProvider(`${window.location.origin}/api/proof`);
    proofProvider = createProofProvider(httpProvider);
  }

  return { zkConfigProvider, privateStateProvider, publicDataProvider, proofProvider,
           walletProvider, midnightProvider };
}

There is a subtle but important detail here: WalletProvider.getCoinPublicKey() is synchronous in the SDK interface, but the Lace wallet's getShieldedAddresses() is asynchronous. So the code pre-fetches the address early and then closes over the resolved hex strings in the walletProvider implementation. This is the only way to satisfy the interface contract.

The proof provider has a two-path design. Lace v4 introduced wallet-delegated proving, where the wallet extension generates the proof itself. Older setups fall through to a local Docker container running midnightntwrk/proof-server. The Docker path calls the proof server via a Next.js API route /api/proof/prove) rather than directly, this is to avoid CORS errors, since the proof server has no CORS headers.

5.3 The EventTicketAPI class

typescript
// sdk/src/contract-api.ts (structure)
---------------------------------------------------------------------------------------------

export class EventTicketAPI {
  readonly callerSecret: bigint;

  private _pendingTicketNonce: bigint | null = null;
  private _pendingBirthYear: bigint = 0n;

  private constructor(
    private readonly providers: MidnightProviders,
    private readonly _contract: any,
    readonly contractAddress: string,
    callerSecret: bigint,
  ) {
    this.callerSecret = callerSecret;
  }

The class uses a private constructor. You can only get an instance through one of three factory methods: deploy(), join(), or joinAsAttendee(). This prevents misuse — you cannot create an instance with an uninitialised contract reference.

The three witness scalars are stored as instance fields: callerSecret (set at construction), _pendingTicketNonce (set just before calling a circuit, cleared after), and _pendingBirthYear (same). The witness functions passed to the compiled contract are closures that read from these fields:

typescript
---------------------------------------------------------------------------------------------

const witnesses = {
  caller_secret: (context) => [context.privateState, getCallerSecret()],
  ticket_nonce:  (context) => [context.privateState, getTicketNonce()],
  birth_year:    (context) => [context.privateState, getBirthYear()],
};

This is how the private inputs flow into the ZK proof. The SDK calls the circuit, the circuit calls the witness function, the witness function returns the value you set on the instance a moment earlier.

5.4 Claiming a ticket

typescript
---------------------------------------------------------------------------------------------

async claimTicket(birthYear: number): Promise<ClaimTicketResult> {
  this._pendingBirthYear     = BigInt(birthYear);
  this._pendingTicketNonce   = null; // triggers auto-generation of a fresh random Field
  const currentYear          = BigInt(new Date().getFullYear());
  const r                    = await this._contract.callTx.claim_ticket(currentYear);
  const nonce                = this._pendingTicketNonce;
  this._pendingBirthYear     = 0n;
  this._pendingTicketNonce   = null;
  if (nonce === null) throw new Error("Witness did not generate a nonce");
  return { txId: r.public.txId, nonce };
}

The _pendingTicketNonce starts as null. When the witness function is called by the circuit, _getTicketNonce() runs:

typescript
---------------------------------------------------------------------------------------------

private _getTicketNonce(): bigint {
  if (this._pendingTicketNonce === null) {
    this._pendingTicketNonce = randomField();
  }
  return this._pendingTicketNonce;
}

If it is null, it generates a cryptographically random scalar and stores it. After callTx.claim_ticket() returns, the method reads this._pendingTicketNonce which now holds the nonce that was used inside the circuit. This is the attendee's private ticket secret. The caller must save it.

The random scalar is generated within the Pallas curve's scalar field:

typescript
---------------------------------------------------------------------------------------------

const PALLAS_SCALAR_PRIME =
  0x40000000000000000000000000000000224698fc094cf91b992d30ed00000001n;

function randomField(): bigint {
  const buf = new Uint8Array(32);
  crypto.getRandomValues(buf);
  let value = 0n;
  for (let i = 0; i < buf.length; i++) value += BigInt(buf[i]) << BigInt(i * 8);
  return value % PALLAS_SCALAR_PRIME;
}

Midnight uses the Pallas curve for its proving system. A valid field element must be less than the prime order of the scalar field. The code reads 32 random bytes, converts them to a bigint, and takes the modulus. This guarantees the result is a valid scalar, with negligible bias from the mod operation.

6. The Express backend

The backend handles three things: session-based authentication with Google OAuth, off-chain event metadata, and real-time ticket admission events over WebSocket.

It does not interact with the Midnight contract directly. All on-chain operations happen from the frontend's browser context. The backend only records what happened after the fact.

6.1 Configuration with fail-fast validation

typescript
// backend/src/config.ts
---------------------------------------------------------------------------------------------

const schema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  PORT:     z.coerce.number().int().min(1).max(65535).default(4000),
  DATABASE_URL: z.string().url().refine(
    (u) => u.startsWith("postgresql://") || u.startsWith("postgres://"),
    { message: "DATABASE_URL must be a PostgreSQL connection string" },
  ),
  SESSION_SECRET: z.string().min(32, "SESSION_SECRET must be at least 32 characters"),
  SESSION_NAME:   z.string().default("pet.sid"),
  SESSION_TTL_SECONDS: z.coerce.number().int().positive().default(604800),
  CORS_ORIGINS: z.string().default("http://localhost:3000")
    .transform((val) => val.split(",").map((s) => s.trim())),
  GOOGLE_CLIENT_ID: z.string().min(10, "GOOGLE_CLIENT_ID is required"),
});

function loadConfig() {
  const result = schema.safeParse(process.env);
  if (!result.success) {
    console.error("❌  Invalid environment configuration:");
    for (const issue of result.error.issues) {
      console.error(`   ${issue.path.join(".")}: ${issue.message}`);
    }
    process.exit(1);
  }
  return result.data;
}

The process exits before binding a port if anything is wrong. This is intentional. A misconfigured server that starts successfully and fails silently at runtime is much harder to debug than one that refuses to start at all. Zod also handles type coercion : PORT=4000 is a string in process.env but arrives as a number in config.PORT.

6.2 Session security

typescript
// backend/src/app.ts (session middleware)
---------------------------------------------------------------------------------------------

function buildSessionMiddleware() {
  return session({
    name: config.SESSION_NAME,
    secret: config.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    rolling: true,
    store: new PgStore({
      conString: config.DATABASE_URL,
      tableName: "sessions",
      createTableIfMissing: true,
      ttl: config.SESSION_TTL_SECONDS,
      pruneSessionInterval: 3600,
    }),
    cookie: {
      httpOnly: true,
      secure: config.NODE_ENV === "production",
      sameSite: config.NODE_ENV === "production" ? "strict" : "lax",
      maxAge: config.SESSION_TTL_SECONDS * 1000,
    },
  });
}

Sessions are stored in PostgreSQL, not in memory. This survives server restarts. The rolling: true option refreshes the TTL on every response, so an active user is never logged out mid-session. The cookie is httpOnly (JavaScript on the page cannot read it) and sameSite: strict in production (prevents CSRF from cross-origin requests carrying the cookie).

6.3 CSRF protection without tokens

typescript
// backend/src/app.ts (CSRF middleware)
---------------------------------------------------------------------------------------------

app.use((req, res, next) => {
  const mutating = ["POST", "PATCH", "PUT", "DELETE"].includes(req.method);
  if (mutating && req.headers["x-requested-with"] !== "XMLHttpRequest") {
    res.status(403).json({ error: "Forbidden" });
    return;
  }
  next();
});

This is a header-based CSRF defence. A cross-origin form or fetch cannot set arbitrary headers without a CORS preflight, which the server's Access-Control-Allow-Headers policy controls. The X-Requested-With: XMLHttpRequest header is effectively unforgeable from a cross-origin context. This avoids the complexity of CSRF token management while still protecting all state-changing endpoints.

6.4 Google OAuth

typescript
// backend/src/routes/auth.ts
--------------------------------------------------------------------------------------------

router.post("/google", authLimiter, async (req, res, next) => {
  try {
    const { credential } = req.body as { credential?: string };
    if (!credential || typeof credential !== "string") {
      throw createError("Missing credential.", 422);
    }

    const ticket = await googleClient.verifyIdToken({
      idToken: credential,
      audience: config.GOOGLE_CLIENT_ID,
    });

    const payload = ticket.getPayload();
    if (!payload?.sub || !payload.email) {
      throw createError("Invalid Google token payload.", 401);
    }

    const user = await upsertGoogleUser(payload.sub, payload.email, payload.name);

    req.session.regenerate((err) => {
      if (err) return next(err);
      req.session.userId = user.id;
      req.session.email  = user.email ?? payload.email!;
      req.session.save((saveErr) => {
        if (saveErr) return next(saveErr);
        res.status(200).json({ userId: user.id, email: user.email, name: user.name });
      });
    });
  } catch (err) {
    next(err);
  }
});

verifyIdToken() hits Google's public key endpoint to validate the JWT signature. audience: config.GOOGLE_CLIENT_ID ensures the token was issued for this specific application, without this check, a token generated for a different app would be accepted.

req.session.regenerate() is called before writing user data into the session. This replaces the session ID with a fresh one, preventing session fixation attacks where an attacker plants a known session ID before the user logs in.

6.5 The database schema

prisma
// backend/src/prisma/schema.prisma
---------------------------------------------------------------------------------------------

model User {
  id              String   @id @default(uuid()) @db.Uuid
  googleId        String?  @unique @db.VarChar(200)
  email           String?  @unique @db.VarChar(200)
  name            String?  @db.VarChar(200)
  shieldedAddress String?  @unique @db.VarChar(200)
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  hostedEvents Event[]  @relation("HostedBy")
  tickets      Ticket[]
}

model Event {
  id              String   @id @default(uuid()) @db.Uuid
  contractAddress String   @unique @db.VarChar(200)
  name            String   @db.VarChar(200)
  description     String   @db.Text
  location        String   @db.Text
  country         String?  @db.VarChar(100)
  city            String?  @db.VarChar(200)
  latitude        Float?
  longitude       Float?
  startDate       DateTime
  endDate         DateTime
  maxCapacity     Int
  minAge          Int      @default(0)
  isActive        Boolean  @default(true)
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  hostId  String @db.Uuid
  host    User   @relation("HostedBy", fields: [hostId], references: [id], onDelete: Cascade)
  tickets Ticket[]
}

model Ticket {
  id         String    @id @default(uuid()) @db.Uuid
  claimTxId  String    @unique @db.VarChar(256)
  isVerified Boolean   @default(false)
  verifiedAt DateTime?
  createdAt  DateTime  @default(now())
  updatedAt  DateTime  @updatedAt

  eventId    String @db.Uuid
  event      Event  @relation(fields: [eventId], references: [id], onDelete: Cascade)

  attendeeId String @db.Uuid
  attendee   User   @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
}

Notice that the Ticket model stores claimTxId , the public blockchain transaction ID; but not the ticket nonce. The nonce is the private ticket secret. It never leaves the attendee's browser. The backend can confirm that a ticket was claimed (the tx exists on-chain) and whether it has been admitted isVerified), but it cannot reproduce or forge the QR code.

6.6 Real-time admission with Socket.io

typescript
// backend/src/socket.ts
---------------------------------------------------------------------------------------------

export function createSocketServer(
  httpServer: HttpServer,
  sessionMiddleware: SessionMiddleware,
  corsOrigins: string[]
): SocketServer {
  const io = new SocketIOServer(httpServer, {
    cors: { origin: corsOrigins, credentials: true },
    transports: ["websocket"], // no long-polling fallback
  });

  // Share the express-session middleware — parses the session cookie on WS handshake
  io.engine.use(sessionMiddleware);

  io.use((socket, next) => {
    const req  = socket.request as express.Request;
    const sess = req.session;
    if (!sess?.userId) {
      next(new Error("Unauthorized"));
      return;
    }
    socket.data.userId = sess.userId;
    next();
  });

  io.on("connection", (socket) => {
    socket.on("event:join", (contractAddress: string) => {
      if (!/^[a-zA-Z0-9_:-]{5,200}$/.test(contractAddress)) {
        socket.emit("error", "Invalid contract address.");
        return;
      }
      void socket.join(`event:${contractAddress}`);
    });
  });

  return io;
}

The session middleware is shared between Express and Socket.io by passing it to io.engine.use(). This means the same session cookie that identifies you on HTTP requests also identifies you on WebSocket connections. No separate auth token is needed.

The contract address validation with a regex is important. Without it, an attacker could send a specially crafted string as a room name and potentially join rooms they should not be in.

When a ticket is admitted, the tickets route emits to the event's room:

typescript
// inside the admit route handler
---------------------------------------------------------------------------------------------

io.to(`event:${contractAddress}`).emit("ticket:admitted", {
  ticketId:   ticket.id,
  eventId:    ticket.eventId,
  claimTxId:  ticket.claimTxId,
  verifiedAt: ticket.verifiedAt,
});

The attendee's browser is subscribed to this room. When the event fires, the ticket in localStorage gets an isUsed: true flag and the ADMITTED stamp appears — no page refresh required.

7. The Next.js frontend

The frontend is built with Next.js 15 App Router and follows a strict separation of concerns:

  • *hooks/**: all business logic

  • *contexts/**: global state providers

  • *app/*/_components/**: route-private components

  • *components/**: shared pure rendering components

  • *lib/**: utility functions, API client, localStorage helpers

Pages are thin shells. They call a hook and render what it returns.

7.1 The localStorage helpers

typescript
// frontend/lib/storage.ts
---------------------------------------------------------------------------------------------

export interface SavedTicket {
  id: string;
  contractAddress: string;
  eventName: string;
  claimTxId?: string;
  secret: { contractAddress: string; nonce: string };
  receivedAt: string;
  isUsed?: boolean;
  usedAt?: string;
}

function read<T>(key: string, fallback: T): T {
  if (typeof window === "undefined") return fallback;
  try {
    const raw = localStorage.getItem(key);
    return raw ? (JSON.parse(raw) as T) : fallback;
  } catch {
    return fallback;
  }
}

function write<T>(key: string, value: T): void {
  if (typeof window === "undefined") return;
  try {
    localStorage.setItem(key, JSON.stringify(value));
  } catch { /* Storage full — ignore */ }
}

The typeof window === "undefined" guard is the SSR safety check. Next.js renders pages on the server before sending them to the browser. On the server, window does not exist. Without this guard, localStorage access would throw a ReferenceError during server rendering.

The saveCallerSecret function is separate from saveEvent:

typescript
---------------------------------------------------------------------------------------------

export function saveCallerSecret(contractAddress: string, secretHex: string): void {
  write(`mt_secret_${contractAddress}`, secretHex);
}

This is deliberate. The event list is readable to display events. The caller secret gives complete control over the event contract. Storing it under a separate key means reading the event list does not expose the secret.

7.2 The API client

typescript
// frontend/lib/api.ts
---------------------------------------------------------------------------------------------

const BASE = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";

const DEFAULT_HEADERS: Record<string, string> = {
  "Content-Type": "application/json",
  "X-Requested-With": "XMLHttpRequest",
};

async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    credentials: "include",
    headers: { ...DEFAULT_HEADERS, ...(init.headers as Record<string, string>) },
  });

  if (!res.ok) {
    const body = await res.json().catch(() => ({ message: res.statusText }));
    throw new ApiError(res.status, (body as { message?: string }).message ?? res.statusText);
  }

  return res.json() as Promise<T>;
}

Every request includes credentials: "include" (sends the session cookie cross-origin) and X-Requested-With: XMLHttpRequest (satisfies the backend's CSRF check). The ApiError class carries the HTTP status code so calling code can distinguish 401 (not logged in) from 409 (conflict) from 500 (server error).

7.3 Authentication context

typescript
// frontend/contexts/AuthContext.tsx
---------------------------------------------------------------------------------------------

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser]     = useState<BackendUser | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    api.auth.me()
      .then(setUser)
      .catch(() => {})
      .finally(() => setLoading(false));
  }, []);

  const signIn = useCallback(async (credential: string) => {
    const u = await api.auth.google(credential);
    setUser(u);
  }, []);

  const signOut = useCallback(async () => {
    await api.auth.disconnect().catch(() => {});
    setUser(null);
  }, []);

  return (
    <AuthContext.Provider value={{ user, loading, signIn, signOut }}>
      {children}
    </AuthContext.Provider>
  );
}

On mount, api.auth.me() attempts to restore a session from an existing cookie. If the cookie is valid, the user is populated without any login prompt. The error is silently swallowed — "no session" is not an error state, it just means the user needs to log in.

7.4 Wallet context

typescript
// frontend/hooks/useWallet.ts (connect function)
---------------------------------------------------------------------------------------------

const connect = useCallback(async (walletKey?: string): Promise<WalletConnectedAPI> => {
  if (walletRef.current) return walletRef.current;

  setStatus("connecting");
  setError(null);

  const midnightObj = (window as unknown as { midnight?: Record<string, InitialAPI> }).midnight;
  if (!midnightObj || Object.keys(midnightObj).length === 0) {
    throw new Error(
      "No Midnight wallet detected. Install a Midnight-compatible wallet.",
    );
  }

  const selectedKey = walletKey && midnightObj[walletKey]
    ? walletKey
    : Object.keys(midnightObj)[0];
  const initialApi  = midnightObj[selectedKey];

  const connected = await initialApi.connect("preprod");
  walletRef.current = connected as WalletConnectedAPI;
  setWallet(connected as WalletConnectedAPI);

  try {
    const addresses = await connected.getShieldedAddresses();
    setShieldedPubkey(addresses.shieldedAddress ?? null);
  } catch {
    // Non-fatal — display only
  }

  setStatus("connected");
  return connected as WalletConnectedAPI;
}, []);

The walletRef is a useRef in parallel with the wallet state. This is because walletRef.current is readable inside async functions without stale closure issues. The early-return if (walletRef.current) return walletRef.current prevents duplicate connection attempts if connect() is called from multiple places simultaneously.

window.midnight is the standard injection point for Midnight wallets. The Lace extension injects itself as window.midnight.mnLace. The code handles multiple wallets by listing all keys and either using the one the caller requested, or defaulting to the first.

7.5 The claim ticket hook

typescript
// frontend/hooks/useClaimTicket.ts
---------------------------------------------------------------------------------------------

export function useClaimTicket(
  contractAddress: string,
  minAge: number,
  eventName: string,
): UseClaimTicketReturn {
  const { wallet, connect } = useWallet();
  const [claiming, setClaiming]     = useState(false);
  const [claimError, setClaimError] = useState<string | null>(null);

  async function handleClaim(dob: string): Promise<SavedTicket | null> {
    // Client-side age pre-check — fast feedback, no ZK proof needed
    if (minAge > 0) {
      const birthYear = new Date(dob).getFullYear();
      const age = new Date().getFullYear() - birthYear;
      if (age < minAge) {
        setClaimError(`You must be at least ${minAge} years old.`);
        return null;
      }
    }

    setClaiming(true);
    setClaimError(null);

    try {
      const liveWallet = wallet ?? (await connect());
      const birthYear  = new Date(dob).getFullYear();

      // Dynamic imports — mandatory to avoid SSR crash from the Midnight SDK
      const [
        { createEventTicketProviders },
        { EventTicketAPI },
        { PREPROD_CONFIG },
      ] = await Promise.all([
        import("@sdk/providers"),
        import("@sdk/contract-api"),
        import("@sdk/types"),
      ]);

      const providers   = await createEventTicketProviders(liveWallet, PREPROD_CONFIG);
      const contractApi = await EventTicketAPI.joinAsAttendee(providers, contractAddress);
      const { nonce, txId } = await contractApi.claimTicket(birthYear);
      const secret = contractApi.ticketSecret(nonce);

      const ticket: SavedTicket = {
        id: crypto.randomUUID(),
        contractAddress,
        eventName,
        claimTxId: txId,
        secret,
        receivedAt: new Date().toISOString(),
      };
      saveTicket(ticket);

      // Non-fatal backend sync
      try {
        const backendEvent = await api.events.byAddress(contractAddress);
        await api.tickets.issue({ claimTxId: txId, eventId: backendEvent.id });
      } catch {
        console.warn("[ticket] Backend sync failed — ticket is on-chain.");
      }

      return ticket;
    } catch (err) {
      setClaimError(parseContractError(err, minAge));
      return null;
    } finally {
      setClaiming(false);
    }
  }

  return { claiming, claimError, handleClaim, clearError: () => setClaimError(null) };
}

The three SDK imports are wrapped in Promise.all to load all three modules in parallel, this saves about a second of load time since they are independent.

The backend sync after the claim is wrapped in its own try/catch and the error is swallowed. The reason: by the time saveTicket(ticket) runs, the ticket is already secured on-chain and in the browser. A backend database failure does not invalidate the ticket. The user is not shown an error for something that has no practical consequence to them.

7.6 The admit ticket hook

typescript
// frontend/hooks/useAdmitTicket.ts (submitAdmit)
---------------------------------------------------------------------------------------------

async function submitAdmit(rawNonce: string, claimTxId?: string | null) {
  const trimmed = rawNonce.trim();
  if (!trimmed || state.admitting) return;

  patch({ admitting: true, admitResult: null, admitError: null, admitRetry: null });

  let lastErr: unknown;
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
    try {
      const contractApi = await build();
      const { hexToBigint } = await import("@sdk/contract-api");
      const nonce = hexToBigint(trimmed.startsWith("0x") ? trimmed : `0x${trimmed}`);
      await contractApi.admitTicket(nonce);

      setState((s) => ({
        ...s,
        admitting: false,
        admitResult: "success",
        lastAdmittedAt: new Date(),
        lastAdmittedNonce: trimmed,
        admittedNonces: new Set([...s.admittedNonces, trimmed]),
      }));

      if (claimTxId) api.tickets.admit(claimTxId).catch(() => {});
      onTicketsRefresh();
      return;
    } catch (err) {
      lastErr = err;
      const msg = err instanceof Error ? err.message : String(err);
      const isTimeout = /timed out|timeout/i.test(msg);
      if (!isTimeout || attempt === MAX_RETRIES) break;
      patch({ admitRetry: { attempt, max: MAX_RETRIES } });
      await new Promise<void>((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
    }
  }

  patch({ admitting: false, admitResult: "error", admitError: String(lastErr) });
}

Midnight transactions on preprod occasionally time out due to network latency. The retry loop retries up to three times with a four-second delay, but only for timeout errors. Contract logic errors (ticket not found, already admitted, not authorised) fail immediately without retrying, those are user errors, not transient network issues.

The QR scan parser:

typescript
---------------------------------------------------------------------------------------------

function handleQrScan(raw: string): boolean {
  try {
    const parsed = JSON.parse(raw) as {
      contractAddress?: string;
      nonce?: string;
      claimTxId?: string;
    };
    if (
      typeof parsed.contractAddress === "string" &&
      typeof parsed.nonce === "string" &&
      parsed.nonce.startsWith("0x")
    ) {
      patch({ scanActive: false, pendingNonce: parsed.nonce, pendingClaimTxId: parsed.claimTxId ?? null });
      return true;
    }
  } catch {
    // Not valid JSON — keep scanning
  }
  return false;
}

The QR code contains JSON with contractAddress, nonce, and optionally claimTxId. The nonce.startsWith("0x") check is a minimal validation that the value looks like a hex field scalar. Anything that fails this check is ignored and the scanner keeps running.

8. The wallet integration

The wallet integration deserves its own section because it involves several layers that are easy to get wrong.

8.1 The DApp Connector API

Midnight wallets inject themselves into window.midnight. The injected object implements the InitialAPI interface:

typescript
---------------------------------------------------------------------------------------------

interface InitialAPI {
  name: string;
  icon?: string;
  connect(networkId: string): Promise<ConnectedAPI>;
}

Calling connect("preprod") triggers a permission prompt in the wallet extension. The user approves, and you receive a WalletConnectedAPI object with methods like getShieldedAddresses(), getProvingProvider(), and balanceUnsealedTransaction().

### 8.2 Why all SDK imports are dynamic

The Midnight SDK: specifically @midnight-ntwrk/ledger-v8, calls readFileSync at module load time. In a Next.js application, modules imported at the top of a file are loaded during server-side rendering, where readFileSync is fine in principle, but the SDK is trying to read files that only exist in the browser environment. The result is an unrecoverable crash.

The solution: every import of any Midnight SDK module must be inside an async function, using dynamic import():

typescript
---------------------------------------------------------------------------------------------

// This is wrong — causes SSR crash:
import { createEventTicketProviders } from "@sdk/providers";

// This is correct:
async function handleDeploy() {
  const { createEventTicketProviders } = await import("@sdk/providers");
  // ...
}

import() is lazy, the module is not loaded until the function runs, which only happens in the browser after user interaction.

8.3 The proof server proxy

ZK proof generation is CPU-intensive and can take one to four minutes. The frontend proxies all proof requests through a Next.js API route to avoid CORS restrictions:

typescript
// frontend/app/api/proof/prove/route.ts
---------------------------------------------------------------------------------------------

export const maxDuration = 300; // 5-minute timeout

const DEFAULT_PROOF_SERVER = process.env.PROOF_SERVER_URL ?? "http://localhost:6300";

export async function POST(req: NextRequest) {
  const proofServer = resolveProofServer(req);
  const body        = await req.arrayBuffer();

  const upstream = await fetch(`${proofServer}/prove`, {
    method: "POST",
    headers: { "Content-Type": "application/octet-stream" },
    body,
    signal: AbortSignal.timeout(290_000),
  });

  const responseBody = await upstream.arrayBuffer();
  return new NextResponse(responseBody, {
    status: upstream.ok ? 200 : upstream.status,
    headers: { "Content-Type": "application/octet-stream" },
  });
}

The resolveProofServer function has a SSRF (Server-Side Request Forgery) guard:

typescript
---------------------------------------------------------------------------------------------

function resolveProofServer(req: NextRequest): string {
  const header = req.headers.get("x-proof-server");
  if (header) {
    try {
      const url     = new URL(header);
      const allowed = url.hostname === "localhost"
        || url.hostname === "127.0.0.1"
        || url.hostname.endsWith(".midnight.network");
      if (allowed) return header.replace(/\/$/, "");
    } catch { /* ignore malformed */ }
  }
  return DEFAULT_PROOF_SERVER;
}

Without this guard, a browser could send an x-proof-server header pointing to an internal network address, and the Next.js server would proxy requests there. The allowlist restricts forwarding to localhost and Midnight's own infrastructure.

9. The full ticket flow, end-to-end

Let's trace the complete journey of a ticket from deployment to admission.

Step 1: Deploy

Browser (Organiser) → New Event page → handleDeploy()
  → createEventTicketProviders(wallet, PREPROD_CONFIG)
  → EventTicketAPI.deploy(providers)          [~30–90 s]
    → deployContract()
    → api.callerSecret                         ← random Field scalar, generated once
  → api.createEvent("Jazz Night", 200n, 18)   [separate tx, ~30–90 s]
  → saveCallerSecret(contractAddress, hex)     ← into localStorage
  → saveEvent(...)                             ← into localStorage
  → backendApi.events.create(...)              ← into PostgreSQL

Step 2: Claim

Browser (Attendee) → Event page → claim button → handleClaim("1995")
  → useClaimTicket.handleClaim("1995")
    → pre-check: 2026 - 1995 = 31 >= 18 ✓
    → EventTicketAPI.joinAsAttendee(providers, contractAddress)
    → contractApi.claimTicket(1995)
      → circuit generates: commitment = persistentHash(randomField())
      → ZK proof: "birth_year witness satisfies age >= 18, I won't say what it is"
      → on-chain: ticket_commitments.insert(commitment)  [~30–120 s]
    → nonce returned: 0x1a2b3c...
    → ticket QR = JSON.stringify({ contractAddress, nonce: "0x1a2b3c...", claimTxId })
    → saveTicket(ticket)                        ← localStorage
    → api.tickets.issue({ claimTxId, eventId }) ← PostgreSQL

Step 3: Admit

Browser (Organiser) → event dashboard → scan QR
  → handleQrScan(rawJson)
    → parse { contractAddress, nonce: "0x1a2b3c..." }
  → submitAdmit("0x1a2b3c...", claimTxId)
    → EventTicketAPI.join(providers, contractAddress, callerSecret)
    → contractApi.admitTicket(0x1a2b3c...)
      → circuit verifies: hash(0x1a2b3c...) ∈ ticket_commitments ✓
      → circuit verifies: hash(0x1a2b3c...) ∉ used_tickets ✓
      → on-chain: used_tickets.insert(commitment)  [~30–90 s]
    → api.tickets.admit(claimTxId)               ← PostgreSQL isVerified = true
    → io.emit("ticket:admitted", ...)             ← WebSocket broadcast

Step 4: ADMITTED stamp appears

Browser (Attendee) → socket listener
  → "ticket:admitted" event received
  → markTicketUsed(ticket.id, verifiedAt)        ← localStorage update
  → TicketCard re-renders with ADMITTED stamp

10. Security decisions explained

Why not store private data in a database?

The ticket nonce is the only thing that can produce a valid QR code. Storing it in a database means the database operator can create duplicate tickets. Keeping it exclusively in the attendee's browser localStorage) means the only entity that can produce the QR is the person who claimed the ticket.

The trade-off is that if an attendee loses their localStorage (clears browser storage, changes devices), they lose the ticket. This is acknowledged in the README as a known limitation. A recovery mechanism is possible in v2.

Why hashes on-chain instead of commitments?

persistentHash in Compact uses a collision-resistant hash function native to the Midnight proving system. On-chain, every identity and every ticket is stored as a hash of its private preimage. An observer on the Midnight ledger can see:

  • That an event exists with some capacity and min age

  • That tickets were claimed and admitted (how many)

  • That certain hashes are in certain sets

They cannot derive any identity, birth date, or ticket secret from what they see on-chain.

Why use Uint<16> years instead of full dates for age verification?

Two reasons. First, year-level precision is all that is needed, "you were born in 1995 or earlier" is sufficient to satisfy "you must be at least 18 in 2026." Full date precision would require disclosing more information than necessary. Second, small integer arithmetic is significantly cheaper in ZK circuits than large timestamp arithmetic, which directly impacts proof generation time.

Why rate-limit the auth endpoints more aggressively?

typescript
---------------------------------------------------------------------------------------------

export const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 30,
});

Brute-forcing Google tokens is not a realistic attack vector, but enumeration of valid Google user IDs (via the googleId unique constraint) is a potential side channel. A strict rate limit on the /auth/google endpoint prevents automated probing.

Why validate the contract address in Socket.io rooms?

typescript
---------------------------------------------------------------------------------------------

if (!/^[a-zA-Z0-9_:-]{5,200}$/.test(contractAddress)) {
  socket.emit("error", "Invalid contract address.");
  return;
}

Without this, a client could send any string as a room name. Room names that look like ../secrets or contain null bytes or extremely long strings could cause unexpected behaviour in Socket.io's room management. The allowlist regex is conservative but safe.

11. Running the project locally

Prerequisites

You need Node.js 20+, pnpm 9+, Docker Desktop, and the 1AM or Lace browser extension with the Midnight network enabled.

Get test tokens (tDUST) from the Midnight faucet at https://docs.midnight.network/develop/tutorial/using/faucet before starting — gas fees on preprod are paid in tDUST.

Step 1: Clone and install

bash
git clone https://github.com/Mechack08/private-event-tickets.git
cd private-event-tickets
pnpm install

Step 2: Configure the backend

bash
cp backend/.env.example backend/.env

Open backend/.env. Replace SESSION_SECRET with a real random string:

bash
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"

Replace GOOGLE_CLIENT_ID with your OAuth client ID from [Google Cloud Console](https://console.cloud.google.com/) → APIs & Services → Credentials → Web application. Set http://localhost:3000 as the authorised JavaScript origin.

Step 3: Configure the frontend

bash
cp frontend/.env.local.example frontend/.env.local

Open frontend/.env.local and replace <your-client-id> with the same Google Client ID.

Step 4: Start the database

bash
pnpm db:up
pnpm backend:db:migrate

Step 5: Start the proof server

bash
pnpm proof-server:start

The first run pulls the midnightntwrk/proof-server Docker image (~200 MB). Subsequent starts are instant.

Step 6: Start the application

bash
pnpm dev

Check that both services are healthy:
bash
curl http://localhost:4000/health
# {"status":"ok","ts":"..."}

Open http://localhost:3000 in the browser with Lace installed.

What to expect

Creating an event takes 60–180 seconds. The contract is being deployed to Midnight preprod, which processes one block every few seconds.

Claiming a ticket also takes 60–180 seconds. The browser is generating a ZK proof and submitting it on-chain.

Admitting a ticket at the venue takes 30–90 seconds. The organiser's wallet submits a transaction marking the commitment as used.

These are preprod timings. The Midnight team is actively working on proof generation performance.

What we built

In about 3,000 lines of TypeScript and 200 lines of Compact, we built a system where:

  • An organiser deploys a ticketing contract by deploying to a blockchain. No backend account is needed to do that.

  • An attendee claims a ticket by proving their age without disclosing it. No identity is revealed.

  • An organiser admits an attendee by scanning a QR code. The admission is permanent and tamper-proof.

  • An attendee sees their ADMITTED stamp in real time, via WebSocket.

The privacy guarantees are not policy statements. They are mathematical. The contract is written so that disclosing birth year is simply not a step in any circuit. There is no server to opt out of. The proof is the guarantee.

That is the fundamental value proposition of Midnight: programmable privacy as a first-class feature of the execution environment, not an afterthought.

The full source code is available at github.com/Mechack08/private-event-tickets.

Community

Discussion

Join the conversation

Connect your wallet to share your thoughts and engage with the community

No comments yet

Connect your wallet to be the first to comment!

BY

Written by

Mechack Elie (8pro)

Mechack Elie (8pro)

Web3 builder and open-source contributor, creating Eightblock, a wallet-based blogging platform for Cardano and blockchain education.

addr1qyej7l3mctvtqjvtxsr