How I built a zero-knowledge ad-tech stack in a weekend using AI-assisted development, Midnight's programmable data protection, and a lot of coffee.
"The best ads are the ones you actually want to see — but that shouldn't
require surrendering your entire digital life to get there."
I built AdMidnight at the Midnight Hackathon — a
privacy-preserving ad-tech platform where advertisers can target users
by behavioral segment without ever seeing raw user data, users get
verifiably rewarded for their attention, and every settlement happens
transparently on-chain. No surveillance. No data brokers. Just
zero-knowledge proofs doing what they were designed to do.
This article is the full story: the problem, the technology, the
architecture decisions, the AI-assisted development workflow, the
safeguards we put in place, and every ugly thing that broke at 2am.
Table of Contents
- The Problem with Advertising
- What is Midnight?
- The MLH Hackathon Context
- Architecture Overview
- The ZK Proof Flow
- Building the Stack
- AI-Assisted Development
- Code Review with CodeRabbit
- Safeguards and Quality Gates
- The Ugly Parts
- What I'd Do Differently
- What's Next
The Problem with Advertising
Digital advertising as it exists today is a privacy disaster. The
current model works roughly like this:
- You visit a website
- A tracker records your behavior
- That data gets sold to a data broker
- An advertiser buys a profile that includes you
- You see an ad
- You have no idea any of this happened and received nothing for it
The advertiser paid for your attention. The platform took the money.
You got nothing except a feeling of being watched.
The technical problem is equally bad on the advertiser side. To
target effectively, advertisers need behavioral signals — but those
signals are locked inside walled gardens (Google, Meta, Apple).
Every ad network that wants to compete has to either build its own
surveillance infrastructure or pay the gatekeepers.
There's a better model. What if:
- Users computed their own behavioral embedding locally on their device
- A ZK proof attested "this user matches segment X" without revealing why they match
- Advertisers bid on that proof, not on the user's identity
- The whole settlement happened on a public ledger with no single party controlling the data
That's AdMidnight.
What is Midnight?
Midnight is a data protection blockchain
built by Input Output (the team behind Cardano). It's designed for
exactly this kind of use case: applications where you need the
auditability of a public blockchain but the privacy guarantees of
zero-knowledge cryptography.
Programmable Data Protection
What makes Midnight different from other ZK-enabled chains is its
concept of programmable data protection. Rather than bolting privacy
onto an existing smart contract model, Midnight was designed from the
ground up with two distinct data zones:
- Public state: visible on-chain to everyone (aggregate counters, settlement hashes, nullifier sets)
- Private state: shielded by ZK proofs, accessible only to the data owner
This isn't just confidential transactions. It's a full smart contract
model where you can write logic that operates over private inputs
without those inputs ever being revealed to validators or other
participants.
Compact: Midnight's Contract Language
Midnight contracts are written in Compact, a domain-specific
language designed for ZK circuit compilation. A Compact contract
looks superficially like TypeScript but compiles down to arithmetic
circuits that can generate and verify zero-knowledge proofs.
Here's a simplified example of what an AdMidnight match registry
looks like in Compact:
// AdMatchRegistry.compact
circuit proveSegmentMatch(
segmentId: Bytes<32>,
campaignId: Bytes<32>,
nullifier: Bytes<32>,
commitmentHash: Bytes<32>
): Boolean {
// Verify this nullifier hasn't been used before
assert !claimedNullifiers.member(nullifier);
// Record the impression
campaignImpressions.set(
campaignId,
campaignImpressions.lookup(campaignId) + 1
);
// Mark nullifier as spent
claimedNullifiers.insert(nullifier);
return true;
}
The key insight: nullifier is a private input. The circuit proves
it's valid without the chain ever knowing what it is. Double-spend
prevention works through the nullifier set — but the nullifier itself
is a hash of the user's private data, so it reveals nothing.
The Midnight SDK
Midnight provides a TypeScript SDK for connecting to contracts from
off-chain applications. The SDK handles:
- Provider setup (wallet, ZK proof generation, transaction submission)
- Contract deployment and address management
-
callTxwrappers for circuit invocations - Indexer queries for reading contract state
The SDK is ESM-only and fairly new, which caused some interesting
integration challenges with NestJS (more on that later).
The MLH Hackathon Context
Major League Hacking (MLH) runs hundreds of hackathons a year, from
24-hour sprints to week-long builds. This event was a Midnight Hackathon
hackathon with a focus on opportunity to build on a fully launched network designed to solve the internet's biggest privacy challenges.
The Rules That Shaped the Build
MLH hackathons have strict rules that actually make you a better
engineer:
- No prior work: everything must be built during the event weekend. This forces you to make architecture decisions fast and commit to them.
- Public repo: your code must be public and stay public. This makes you think about what you're actually committing.
- 2-minute demo video: ruthless constraint on scope. If you can't explain it in 2 minutes, you haven't understood it.
- Team size max 4: I went solo, which meant every architectural decision was mine to live with.
Going solo at a hackathon in 2026 is a different experience than it
was even two years ago. AI tooling has genuinely changed what one
person can ship in a weekend. But it comes with its own set of
traps — more on that in the AI section.
Architecture Overview
AdMidnight is a monorepo with four main execution layers:
apps/
api/ # NestJS + Fastify backend
advertiser-dashboard/ # Next.js 14 App Router
mobile/ # Flutter (on-device ZK)
packages/
zk-circuits/ # Midnight Compact contracts
midnight-sdk-wrapper/ # ESM bridge for Midnight SDK
shared/ # Shared types and DTOs
Why a Monorepo?
The shared types between the API and dashboard were the deciding
factor. When the API returns a CampaignResponseDto, I want the
dashboard to have the exact same TypeScript type without copying.
pnpm workspaces made this straightforward with local package
references.
The Four Execution Layers
Layer 1: Advertiser Dashboard (Next.js 14)
Server components fetch data directly from the API using the session
cookie. Client components handle form interactions. The App Router's
layout system handles auth gating via middleware — unauthenticated
requests to /campaigns/* redirect to /login before they hit
the server component.
Layer 2: NestJS API
The API is the trust boundary. It validates every request with
NestJS global validation pipes, enforces JWT auth via guards, and
is the sole path from the application layer into the Midnight
contracts. Nothing touches the contracts except through
MidnightGateway.
Key modules:
-
AuthModule— JWT issuance, cookie management -
AdvertiserModule— campaign CRUD, auction flow -
UserModule— proof submission, reward claims -
PublisherModule— impression registration, revenue -
MidnightModule— gateway, provider service, indexer queries
Layer 3: Flutter Mobile App
The mobile app is where the privacy guarantee lives. The user's
behavioral embedding never leaves the device. The app:
- Fetches active segment definitions from
/user/segments/available - Computes a 128-dimensional embedding from local behavioral signals
- Runs cosine similarity against segment centroids
- If threshold met, calls the ZK proof engine to build a proof envelope
- Submits only
proofBytes + publicInputs + generatedAtto the API
The raw embedding — the thing that actually reveals user behavior —
never hits a network request.
Layer 4: Midnight Compact Contracts
Three contracts handle the on-chain state:
-
AdMatchRegistry: impression counting, nullifier set, segment registry -
AdAuction: sealed bid commitments, reveal phase, winner settlement -
UserReward: reward escrow, claim verification, spend tracking
The contracts are compiled from Compact source and deployed to the
Midnight devnet. Contract addresses are stored in .env.local after
deployment and referenced by the API gateway.
The ZK Proof Flow
This is the core of the system. Here's the full sequence:
Mobile App NestJS API Midnight Ledger
│ │ │
│ Load segment centroids │ │
│ ◄────────────────────────── │ │
│ │ │
│ [Local computation] │ │
│ - Compute embedding │ │
│ - Cosine similarity check │ │
│ - Build ZK proof envelope │ │
│ │ │
│ POST /user/proof/match │ │
│ { proofBytes, │ │
│ publicInputs, │ │
│ generatedAt } │ │
│ ────────────────────────── ► │ │
│ │ verifyAndBind │
│ │ submitMatchProof │
│ │ ──────────────────── ► │
│ │ │ proveSegmentMatch()
│ │ │ - Check nullifier
│ │ │ - Increment counter
│ │ │ - Escrow reward
│ │ ◄──────────────────── │
│ │ txHash │
│ ◄────────────────────────── │ │
│ { valid: true, │ │
│ rewardEscrow, │ │
│ relayTxHash } │ │
Why Nullifiers?
The nullifier pattern is how ZK systems prevent double-spending
without revealing identity. Here's the intuition:
- The user generates
nullifier = hash(privateKey + campaignId + salt) - The nullifier is included as a public input to the proof
- The contract checks that this nullifier isn't in
claimedNullifiers - After the proof is accepted, the nullifier is added to the set
An observer sees: "a nullifier was spent". They do not see: "which
user spent it". The nullifier is a commitment to the user's identity
without revealing it.
The Sealed Bid Auction
The auction uses a commit-reveal scheme:
-
Commit phase: advertiser submits
commitmentHash = hash(actualBid + nonce) -
Reveal phase: advertiser submits
actualBidandnonce -
Settlement: contract verifies
hash(actualBid + nonce) == commitmentHashon-chain
This prevents front-running. Other bidders can see that a commitment
exists but cannot learn the bid value until reveal. By then, the
commitment is already locked on-chain.
Building the Stack
Day 1: Foundation
The first day was architecture and scaffolding. I set up the monorepo
with pnpm workspaces, got the NestJS app bootstrapping, and wrote
the Prisma schema.
The Prisma schema ended up being the most important design document
for the whole project. Getting the data models right early meant
the API handlers almost wrote themselves:
model Campaign {
id String @id @default(cuid())
advertiserId String
title String
status String @default("DRAFT")
budgetMidnight String
cpmBidMidnight String
startTime DateTime
endTime DateTime
onChainTxHash String?
advertiser Advertiser @relation(fields: [advertiserId],
references: [id])
bids Bid[]
proofRecords ProofRecord[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Day 2: The Hard Parts
Day 2 was Midnight integration and the Flutter ZK engine. These were
the two highest-risk components — both used bleeding-edge SDKs that
I hadn't worked with before.
The Midnight SDK integration hit an immediate wall: the SDK is
ESM-only but NestJS compiles to CommonJS. In a normal project you'd
just configure "type": "module" in package.json and move on. With
NestJS that breaks the entire DI system.
The fix was a midnight-sdk-wrapper package that builds with
"module": "CommonJS" in its tsconfig, giving NestJS a CJS-compatible
entry point. The wrapper re-exports only what the API needs, keeping
the surface area small.
The Flutter ZK engine was more straightforward. The proof generation
logic lives in zk_proof_engine.dart and uses Dart's isolate
system to run the computation off the main thread:
Future<ProofEnvelope> generateMatchProof({
required List<double> userEmbedding,
required List<double> segmentCentroid,
required double threshold,
required String campaignId,
required String segmentId,
}) async {
return await Isolate.run(() async {
final similarity = _cosineSimilarity(
userEmbedding,
segmentCentroid
);
if (similarity < threshold) {
throw ProofGenerationException('Below threshold');
}
final nullifier = _generateNullifier(campaignId);
final proofBytes = await _buildZKProof(
similarity, nullifier, campaignId, segmentId
);
return ProofEnvelope(
proofBytes: proofBytes,
publicInputs: PublicInputs(
segmentId: segmentId,
campaignId: campaignId,
isMatch: true,
nullifier: nullifier,
),
generatedAt: DateTime.now().toIso8601String(),
);
});
}
The Monorepo Build Problem
With four packages and two apps, the build order matters. pnpm
workspaces handles this via the dependency graph, but Docker builds
don't understand workspace protocols. The Dockerfile needs to copy
the full monorepo context and run filtered installs:
FROM node:20-alpine AS base
RUN npm install -g pnpm
WORKDIR /app
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY packages/ ./packages/
COPY apps/api/ ./apps/api/
RUN pnpm install --filter @admidnight/api... --frozen-lockfile
RUN pnpm --filter @admidnight/api run build
The --filter @admidnight/api... flag (note the ...) tells pnpm
to install the API and all its workspace dependencies. Without the
ellipsis you only get the API's direct dependencies, not the
transitive workspace packages.
AI-Assisted Development
I want to be honest about how I used AI on this project, because I
think the honest version is more useful than the sanitized version.
What Worked Well
Boilerplate generation: NestJS modules follow a rigid pattern —
controller, service, module, DTOs, guards. Generating the initial
scaffold for each module with an AI assistant saved probably 3-4
hours of typing.
Error message interpretation: When you're working with a new SDK
and hitting cryptic TypeScript errors, having an AI that can parse
the error, trace it back to the type definitions, and suggest a fix
is genuinely useful. The ESM/CJS issue with the Midnight SDK was
diagnosed in minutes rather than hours.
Prompt-driven architecture review: I used Claude as a sounding
board for architectural decisions. "Given this constraint, what's
the tradeoff between approach A and approach B?" is a great use of
AI — it forces you to articulate the constraint clearly, and the
response gives you angles you might not have considered.
Documentation: The README, API reference, and this article were
all drafted with AI assistance. The structure and technical accuracy
are mine; the AI helped with prose flow and completeness checking.
What Didn't Work
Long autonomous runs: I gave the AI agent long multi-step
prompts and let it run for extended periods. This is where things
went wrong. The agent would fix one issue, introduce a new one,
fix that, loop back to a previous state, and spend an hour going
in circles on a problem that needed a 5-minute human decision.
The specific failure mode was the Docker build. The agent kept
trying increasingly complex fixes (esbuild transforms, custom
predev scripts, lazy imports) for a problem that had a simple
root cause: the API shouldn't run in Docker during development
at all. That was a 30-second architectural decision. The agent
couldn't make it because it requires stepping back from the
immediate error.
Lesson: give AI agents tightly scoped tasks with clear
success criteria. "Fix this one error and tell me when it's
done" works. "Fix everything until the demo runs" does not.
Type correctness: AI-generated NestJS code had a systematic
bug throughout: injectable dependencies were imported as
import type { Service } instead of import { Service }.
TypeScript type-only imports are erased at compile time, which
breaks NestJS's reflection-based dependency injection at runtime.
The AI never caught this pattern because it's valid TypeScript —
it just doesn't work with NestJS's decorator metadata system.
Lesson: AI knows TypeScript. AI does not always know framework
constraints that diverge from standard TypeScript behavior. You
still need to know your framework.
The Workflow That Actually Worked
By day 2 I had settled on this pattern:
- I write the architecture decision (which module, what responsibility, what the interface looks like)
- AI generates the implementation (the controller, the service, the DTOs)
- I review the output before running it (5 minutes of reading is worth an hour of debugging AI-generated bugs)
- I write the test or at minimum the curl command that proves it works
- AI fixes failures from the test output with a tight prompt: "this curl returns 400, here's the response body, here's the DTO, fix the validation"
This kept the AI in the implementation lane and me in the
architecture lane. It's roughly 10x faster than writing everything
myself and much more reliable than giving the AI architectural
autonomy.
Code Review with CodeRabbit
CodeRabbit is an AI-powered code review
tool that integrates directly with GitHub pull requests. On a solo
hackathon project it fills a critical gap: you have no teammates to
catch your blind spots.
What CodeRabbit Caught
Security issues: CodeRabbit flagged a route in the advertiser
module where the campaign ownership check happened after the database
fetch rather than in the query itself. An advertiser could request
analytics for any campaign ID and get a response before the
authorization check fired. The fix was a one-line Prisma where
clause addition, but it's exactly the kind of thing you miss when
you're moving fast.
Missing input validation: The segmentConfig.centroid array
needed to be exactly 128 entries. The DTO had @IsArray() but not
@ArrayMinSize(128) or @ArrayMaxSize(128). CodeRabbit caught the
missing size constraints.
Unhandled promise rejections: Several service methods had
await prisma.something() without try-catch in error paths.
CodeRabbit flagged these as unhandled rejection risks. In NestJS
this will crash the process rather than returning a 500 to the client.
Overly broad CORS: The initial CORS configuration used
origin: '*' which is fine for development but CodeRabbit correctly
flagged it as a production concern and suggested the env-variable
pattern.
The Review Workflow
I ran CodeRabbit on every commit to the main branch. The workflow:
- Push a feature branch
- Open a PR (even on a solo project — PRs give you a review surface)
- CodeRabbit posts a summary and line-level comments within minutes
- Address the comments before merging
- Merge when CodeRabbit shows no critical issues
On a 48-hour hackathon this might sound like overhead, but it
actually saves time. Catching a security issue during review is
10 minutes. Catching it after you've built three more features
on top of it is an afternoon.
Safeguards and Quality Gates
Building fast doesn't mean building recklessly. These were the
non-negotiable safeguards:
Never Expose Raw User Data
This was the architectural principle everything else flowed from.
The rule: user embeddings never leave the device. If I ever found
myself writing code that sent raw behavioral data to the API, that
was a design failure to fix, not a compromise to accept.
In practice this meant:
- The mobile app's proof engine runs in an isolate with no network access
- The API accepts only
proofBytes + publicInputs— no raw vectors - The Prisma schema has no column for user embeddings
Environment Variable Discipline
No secrets in code. Ever. The Zod config schema at
apps/api/src/config.ts validates every required environment
variable at startup and throws with a descriptive error if any
are missing. The .env.example file documents every variable
with safe placeholder values. The .gitignore excludes .env
and .env.local.
Input Validation Everywhere
NestJS global validation pipes with whitelist: true and
forbidNonWhitelisted: true. This means:
- Unknown properties on request bodies are rejected, not ignored
- Every DTO field is validated before it reaches a controller
- Type coercion only happens where explicitly configured
The segmentConfig.centroid validation is worth calling out
specifically:
@IsArray()
@ArrayMinSize(128)
@ArrayMaxSize(128)
@IsNumber({}, { each: true })
@Min(-1, { each: true })
@Max(1, { each: true })
centroid: number[];
A malformed centroid could corrupt the cosine similarity calculation.
Validate at the boundary.
MIDNIGHT_DEV_MODE
The Midnight devnet is not always available. During development
and CI, MIDNIGHT_DEV_MODE=true causes the gateway to return
realistic mock responses instead of attempting on-chain calls.
This is implemented as a guard at the gateway layer — not scattered
through service logic:
if (this.devMode) {
return {
success: true,
data: {
txHash: `0xdev_${Date.now()}`,
relayTxHash: `0xrelay_${Date.now()}`,
}
};
}
// Real gateway call below
The mock returns the same shape as the real response so the rest
of the application can't tell the difference.
The Ugly Parts
No article about a hackathon build is complete without honesty
about what broke.
The import type Bug
Every injectable service in the initial AI-generated code used
import type { Service } for its dependencies. NestJS uses
TypeScript decorator metadata (reflect-metadata) to resolve
constructor parameter types at runtime. Type-only imports are
erased by the TypeScript compiler, so the metadata is never
written. Result: Nest can't resolve dependencies of the X (?)
errors across every module.
The fix was a global sed across the codebase:
find src -name "*.ts" | xargs sed -i '' \
's/import type { \([A-Za-z]*Service\) }/import { \1 }/g'
The Docker Build Loop
I spent several hours trying to get the API running inside Docker
while the Midnight SDK's ESM-only nature caused the build to fail
in increasingly creative ways. The correct decision — run the API
locally during development, only containerize Postgres and the
Midnight infrastructure — was obvious in retrospect but took too
long to reach.
The rule I'll carry forward: containerize stateful infrastructure,
run stateless applications locally during development. Docker is
not a debugging environment.
The Proof Server Image Tag
The Midnight proof server Docker image had a breaking tag change
between the version in the original compose file and the latest
available image. bricktowers/proof-server:8.0.3 did not exist.
7.0.0 did. This was a 30-minute debugging session that should
have been a 30-second check.
Rule: always pin Docker image tags to versions you have verified
exist. Never use latest in a project you care about.
What I'd Do Differently
Start with the Makefile. The demo runner, e2e tests, and
service startup scripts should be the first thing you write, not
the last. Every hour you spend on "how do I run this?" during the
hackathon is an hour not spent building.
Shorter AI agent prompts with tighter success criteria.
"Run this curl, fix the specific error it returns" is a good
agent prompt. "Make everything work" is not. The agent doesn't
know when to stop.
Review AI output before running it. Five minutes of reading
prevents an hour of debugging. The import type bug was in the
first file the AI generated. If I'd read it before running it
I'd have caught it immediately.
Validate third-party SDK assumptions early. The Midnight SDK
ESM issue was knowable before I wrote a single line of application
code. A one-hour spike on "can I actually import this SDK in a
NestJS app" would have saved four hours of runtime debugging.
What's Next
AdMidnight is a proof of concept, but the core primitives are real
and the use case is genuine. The roadmap from here:
Real on-device proof generation: The current mobile app uses
a simplified proof scheme. The next step is integrating the full
Midnight proof generation SDK on Flutter via a native bridge,
so the ZK proofs are cryptographically sound, not just structurally
correct.
Publisher SDK: The publisher side of AdMidnight is
underbuilt. A JavaScript SDK that publishers can drop into any
website — similar to a Google AdSense tag but privacy-preserving
by construction — would unlock the distribution side of the
marketplace.
Decentralized segment registry: Right now segments are
registered by advertisers through the API. A fully decentralized
model would let segment definitions live entirely on-chain,
with the mobile app fetching them directly from the Midnight
indexer without going through any centralized API.
Privacy-preserving analytics: The current analytics endpoint
returns aggregate counts. The long-term vision is differential
privacy guarantees on the analytics — so even the aggregate numbers
can't be used to infer individual user behavior.
Closing Thoughts
Privacy-preserving advertising sounds like a contradiction in terms
until you've worked with ZK proofs. The math genuinely allows you
to prove membership in a group without revealing which member you are.
Midnight makes that math accessible to application developers through
Compact and a reasonable SDK.
The hackathon format forced the right kind of discipline: make
decisions fast, ship working software, document the real tradeoffs.
AI tooling made it possible for one developer to build what would
normally require a team — but only because I kept myself in the
architecture seat and the AI in the implementation seat.
The code is at https://github.com/Wolfof420Street/AdMidnight.git.
The demo is at [https://youtu.be/yW8hl8WuggY].
If you're building on Midnight or thinking about privacy-preserving
applications, I'd love to talk. Find me on X at [@fbillionaire__] or
open an issue on the repo.
Built with NestJS, Next.js, Flutter, Midnight Compact, and an
unreasonable amount of determination.
Resources
- Midnight Network Documentation
- Compact Language Reference
- MLH Hackathon Events
- CodeRabbit
- AdMidnight GitHub Repository
- pnpm Workspaces
- NestJS Documentation

Top comments (0)