A high-reliability product drop system with durable workflows, real-time updates, and multi-layered bot mitigation. Includes a modern Next.js + Tailwind frontend.
- Durable Execution: Restate virtual objects + timers for reliable drop lifecycle and recovery
- Provable Lottery: Commit/reveal seed + Merkle participant snapshot for independent verification
- Scalable Winner Selection: Weighted selection via Fenwick tree (no O(totalTickets) expansion)
- Backup Winners: Automatic waitlist promotion if winners don’t purchase in time
- Geo-fencing: Optional location restrictions + bonus multipliers
- Bot Resistance: Proof-of-work + behavioral signals + optional FingerprintJS integration
- Real-time UX: SSE for live drop lists, state updates, and queue status
- Purchase Protection: Single-use purchase tokens, expiration, and double-purchase prevention
| Layer | Technology |
|---|---|
| Runtime | Node.js + TypeScript (ESM) |
| Backend Framework | Hono (lightweight, fast) |
| Durable Execution | Restate SDK |
| Messaging | NATS v3 modular client |
| Frontend | Next.js 15 + Tailwind CSS |
| Load Testing | k6, Playwright |
| Package Manager | pnpm |
┌─────────────────────────────────────────────────────────────┐
│ Next.js Frontend :3005 │
│ - Drop listing homepage + drop detail pages │
│ - Queue + PoW client + SSE hooks │
│ - Proxies /api/* and /events/* to backend services │
└─────────────────┬───────────────────────────────────────────┘
│
┌─────────────────▼───────────────────────────────────────────┐
│ API Server (Hono) :3003 │
│ - Bot validation middleware │
│ - Routes: /api/drop/*, /api/drops/*, /api/pow/challenge │
└─────────────────┬───────────────────────────────────────────┘
│
┌─────────────────▼───────────────────────────────────────────┐
│ Restate Runtime (Docker) │
│ - Ingress API: :8080 │
│ - Admin API: :9070 │
│ - Drop & Participant virtual objects │
│ - Durable timers for phase transitions │
└─────────────────┬───────────────────────────────────────────┘
│
┌─────────────────▼───────────────────────────────────────────┐
│ SSE Server (Hono) :3004 │
│ - /events/:dropId/:userId endpoint │
│ - /events/drops stream for live active drop listings │
└─────────────────────────────────────────────────────────────┘
The core state machine uses Restate virtual objects:
- Drop: Manages the lifecycle (registration → lottery → purchase → completed)
- Participant: Tracks individual user state per drop
- UserRollover: Global rollover balance across drops
This provides automatic crash recovery, exactly-once semantics, and durable timers for scheduled phase transitions. When you call:
ctx.objectSendClient(dropObject, dropId, { delay: delayMs }).runLottery({});That timer survives server restarts — Restate persists it and fires it at the right time.
Restate handlers → publish to NATS → SSE server subscribes → pushes to browser
- NATS v3 modular client for pub/sub messaging between services
- Server-Sent Events for browser push (simpler than WebSockets for one-way updates)
- Clock synchronization with exponential moving average to handle client/server drift:
// Smooth out network jitter for accurate countdowns
const alpha = 0.3;
return Math.round(prevOffset * (1 - alpha) + newOffset * alpha);// Memory-efficient weighted selection without replacement (no ticket expansion)
// - Build Fenwick tree over per-user weights
// - Repeatedly sample a weight-indexed random point using a deterministic seed
// - Remove winner weight and continue
const winners = selectWinnersWithMultipliers(entries, seed, winnerCount);- Provable seed via commit/reveal, combined with the participant Merkle root
- Deterministic RNG ensures reproducibility for auditors
- Weighted selection without replacement where more tickets = higher win chance, but any user wins at most once
- Scales to large drops because memory is (O(N)) participants, not (O(\text{totalTickets}))
| Layer | Weight | Technique |
|---|---|---|
| Fingerprinting | 40% | Browser fingerprint confidence score |
| Timing Analysis | 30% | Human-like interaction patterns (1-5s optimal) |
| Proof-of-Work | 30% | SHA-256 challenge solving |
The PoW challenge requires finding a nonce where SHA256(challenge + nonce) starts with N zeros — forces compute cost on the client before registration.
Clever engagement mechanic that reduces the sting of losing:
- Paid entries that lose → convert to rollover credits
- Rollover credits auto-apply to next drop registration (before free entry)
- Stacks up to a maximum to prevent infinite accumulation
// Entry breakdown: rollover first, then free, then paid
const rolloverToUse = Math.min(rolloverBalance, desiredTickets);
const remainingAfterRollover = desiredTickets - rolloverToUse;
const freeEntry = remainingAfterRollover > 0 ? 1 : 0;
const paidEntries = Math.max(0, remainingAfterRollover - freeEntry);Additional tickets cost progressively more, preventing whales from dominating:
// Cost = 1² + 2² + ... + (n-1)² = n(n-1)(2n-1)/6
return ((n * (n + 1) * (2 * n + 1)) / 6) * priceUnit;| Tickets | Total Cost |
|---|---|
| 1 | Free |
| 2 | $1 |
| 3 | $5 |
| 5 | $30 |
| 10 | $285 |
The frontend gracefully handles every edge case with empathetic messaging:
- Winner who didn't purchase in time → "Time Expired" with encouragement
- Missed registration entirely → context-aware messaging based on current phase
- Server clock sync → smooth countdowns that don't jump around
The easiest way to run the project is using the Makefile:
# Install everything
make install
# Start Restate + backend + initialize drop
make dev
# In another terminal, start the frontend
make frontend
# Open http://localhost:3005make status # View drop state
make lottery # Trigger lottery manually
make reset # Quick reset (restart + re-init)
make reset-full # Full reset (clear all state)
make logs # View Restate logs
make help # Show all available commands- Node.js 20+ (or pnpm)
- Docker & Docker Compose
# Install backend dependencies
pnpm install
# Install frontend dependencies
cd web && pnpm install && cd ..# Start Restate Docker container
docker-compose up -d
# Verify it's running
docker-compose ps# Start API server, SSE server, and Restate worker
pnpm devYou should see:
╔══════════════════════════════════════════════════════════╗
║ Product Drop Backend Started ║
╠══════════════════════════════════════════════════════════╣
║ API Server: http://localhost:3003 ║
║ SSE Server: http://localhost:3004 ║
║ Restate Worker: http://localhost:9080 ║
╚══════════════════════════════════════════════════════════╝
# Register the Restate worker (port 9080 is the SDK default)
curl localhost:9070/deployments \
-H 'content-type: application/json' \
-d '{"uri":"http://host.docker.internal:9080"}'# Create a drop with 5-minute registration window
NOW=$(date +%s)000
END=$((NOW + 300000))
curl localhost:8080/Drop/demo-drop-1/initialize \
-H 'content-type: application/json' \
-d "{\"dropId\":\"demo-drop-1\",\"inventory\":10,\"registrationStart\":$((NOW - 1000)),\"registrationEnd\":$END,\"purchaseWindow\":300}"cd web && pnpm devOpen http://localhost:3005
# 1. Stop everything
docker-compose down
# 2. Remove Restate data volume (clears all state)
docker volume rm waitingroom_restate-data 2>/dev/null || true
# 3. Restart Restate
docker-compose up -d
# 4. Re-register worker (after starting backend with pnpm dev)
curl localhost:9070/deployments \
-H 'content-type: application/json' \
-d '{"uri":"http://host.docker.internal:9080"}'
# 5. Initialize a new drop
NOW=$(date +%s)000
END=$((NOW + 300000))
curl localhost:8080/Drop/demo-drop-1/initialize \
-H 'content-type: application/json' \
-d "{\"dropId\":\"demo-drop-1\",\"inventory\":10,\"registrationStart\":$((NOW - 1000)),\"registrationEnd\":$END,\"purchaseWindow\":300}"# Restart just the Restate container (clears in-memory state)
docker-compose restart
# Re-register worker
curl localhost:9070/deployments \
-H 'content-type: application/json' \
-d '{"uri":"http://host.docker.internal:9080"}'
# Re-initialize drop
NOW=$(date +%s)000 && END=$((NOW + 300000)) && \
curl localhost:8080/Drop/demo-drop-1/initialize \
-H 'content-type: application/json' \
-d "{\"dropId\":\"demo-drop-1\",\"inventory\":10,\"registrationStart\":$((NOW - 1000)),\"registrationEnd\":$END,\"purchaseWindow\":300}"# List all deployments
curl localhost:9070/deployments
# Register a new deployment
curl localhost:9070/deployments \
-H 'content-type: application/json' \
-d '{"uri":"http://host.docker.internal:9080"}'
# Force re-register (if services changed)
curl localhost:9070/deployments \
-H 'content-type: application/json' \
-d '{"uri":"http://host.docker.internal:9080","force":true}'# Get drop state
curl localhost:8080/Drop/demo-drop-1/getState \
-H 'content-type: application/json' \
-d '{}'
# Trigger lottery manually
curl localhost:8080/Drop/demo-drop-1/runLottery \
-H 'content-type: application/json' \
-d '{}'
# Get participant state
curl localhost:8080/Participant/demo-drop-1:user-123/getState \
-H 'content-type: application/json' \
-d '{}'# List all invocations (admin API)
curl localhost:9070/invocations
# Get specific invocation
curl localhost:9070/invocations/{invocation_id}| Service | Port | Purpose |
|---|---|---|
| Next.js Frontend | 3005 | User interface |
| API Server | 3003 | REST API endpoints |
| SSE Server | 3004 | Real-time event streaming |
| Restate Ingress | 8080 | Service invocation API |
| Restate Admin | 9070 | Deployment & management API |
| Restate Worker | 9080 | SDK handler endpoint |
| Method | Path | Description |
|---|---|---|
| POST | /api/drop/:id/register |
Register for a drop (requires queue token + bot checks) |
| GET | /api/drop/:id/status |
Get current drop status |
| POST | /api/drop/:id/lottery |
Trigger lottery manually |
| POST | /api/drop/:id/purchase/start |
Start purchase (get token) |
| POST | /api/drop/:id/purchase |
Complete purchase |
| GET | /api/drop/:id/inclusion-proof/:userId |
Fetch Merkle inclusion proof for independent verification |
| Method | Path | Description |
|---|---|---|
| GET | /api/drops/active |
List active drops (used by homepage/SSE snapshot) |
| Method | Path | Description |
|---|---|---|
| GET | /api/pow/challenge |
Get PoW challenge |
| Method | Path | Description |
|---|---|---|
| GET | http://localhost:3004/events/:dropId/:userId |
SSE connection for real-time updates |
| GET | http://localhost:3004/events/drops |
SSE stream of active drops for the homepage |
In production on Fly.io, the frontend typically connects via same-origin
/events/*(proxied by Next.js route handlers).
REGISTRATION → LOTTERY → PURCHASE → COMPLETED
- Registration Phase: Users register with bot validation
- Lottery Phase: Fair random selection of winners
- Purchase Phase: Winners can complete purchases within time window
- Completed: All inventory sold or phase expires
The system uses a multi-layered approach with weighted scoring:
| Layer | Weight | Description |
|---|---|---|
| FingerprintJS | 40% | Browser fingerprinting confidence |
| Timing Analysis | 30% | Human-like interaction patterns (1-5s optimal) |
| Proof-of-Work | 30% | SHA-256 challenge solving |
Trust Score Threshold: 50/100 minimum to pass validation
For local development, copy the templates:
cp env.example .env
cp web/env.example web/.env.localRESTATE_INGRESS_URL=http://localhost:8080
FINGERPRINT_API_KEY=your_fpjs_secret_key # Optional for dev
POW_DIFFICULTY=2 # Leading zero bytes required
API_PORT=3003
SSE_PORT=3004.
├── src/
│ ├── api/ # Hono API server
│ │ ├── routes/ # API route handlers
│ │ └── middleware/ # Bot guard, rate limiting
│ ├── sse/ # SSE server
│ ├── restate/ # Restate virtual objects
│ │ ├── drop.ts # Drop state machine
│ │ └── participant.ts # Participant state
│ ├── lib/ # Shared utilities
│ └── scripts/ # CLI scripts
├── web/ # Next.js frontend
│ ├── app/ # App router pages
│ ├── components/ # React components
│ ├── hooks/ # Custom hooks (SSE, countdown)
│ └── lib/ # API client, types
└── docker-compose.yml # Restate runtime
The backend servers aren't running. Start them with pnpm dev.
Re-register the worker:
curl localhost:9070/deployments \
-H 'content-type: application/json' \
-d '{"uri":"http://host.docker.internal:9080"}'Restart Restate to clear stuck state:
docker-compose restartMake sure you're accessing the frontend at http://localhost:3005 and the SSE server is running on port 3004.
This was fixed - don't use Date.now() at module level. Use useEffect for client-only calculations.
MIT