example-private-party-dapp
Build a private party RSVP dApp on Midnight Network — attendees stay private until unshielded NIGHT check-in crosses the privacy boundary. Covers private-party.compact (no witnesses, persistentCommit, DApp-specific public keys, receiveUnshielded/sendUnshielded), Next.js frontend, 1AM wallet integration, low-level deploy/call, indexer polling, and optional vitest local devnet tests. Use for teaching privacy boundaries, commitment-based guest lists, organizer access control, or unshielded entry fees. Triggers: private party, RSVP dApp, privacy boundary, persistentCommit, getDappPublicKey, unshielded check-in, example-private-party, party organizer, guest list commitment. Also use when extending locker-dapp or payment-dapp wallet/provider patterns to privacy-preserving social flows.
NPX Install
npx skill4agent add kali-decoder/midnight-skills example-private-party-dappTags
Translated version includes tags in frontmatterSKILL.md Content
View Translation Comparison →Midnight Network Private Party DApp
templates/private-party-dapp/npm install && npm run compact && npm run sync:assets && npm run devgithub.com/midnightntwrk/example-private-partyyarn test:local- Scaffold mode (default) — the user wants a working dApp. Use every code block below verbatim; they are the tested reference implementation. Don't rederive them.
- Tutorial mode — the user explicitly wants to learn Compact by writing it themselves. In that case walk through circuit-by-circuit (§5) and let them type it, but still scaffold the surrounding TS/UI from the code blocks — those aren't the teaching surface.
private-party.compact
- —
contract/(no witnesses) + compile scriptsprivate-party.compact - — Next.js client UI (organizer deploy/start/close/claim + attendee RSVP/check-in)
app/party/ - — wallet session + patched indexer provider (full implementation in §9)
lib/midnight.ts - — deploy,
lib/party.ts,rsvp,startParty,checkIn,closeEntry, ledger decodeclaimFees - — Bech32 unshielded address →
lib/address.tsfor{ bytes: Uint8Array }circuit argsUserAddress - — generate/store 32-byte DApp secrets in
lib/secret.tslocalStorage - — ZK proving assets synced from contract build
public/zk/private-party/
- — preprod deploy hangs, GraphQL
references/gotchas.md, ZK asset pathsoffset: null - — pinned
references/versions.jsonversions@midnight-ntwrk/*
- /
example-locker-dapp/— Next.js + 1AM, low-level deploy/calltemplates/locker-dapp/ - — unshielded
example-payment-dapp//receiveUnshieldedpatternssendUnshielded - — vitest + Docker devnet test harness (test script provided in official repo)
example-hello-world/ - —
compact/,disclose(),persistentCommit,persistentHash, sealed ledger, enumsSet - — privacy boundary checklist, what becomes public and when
security/ - — unshielded NIGHT units (Stars), Bech32 address decoding
token-transfers/
- No witnesses — caller auth uses circuit-private →
_secretcompared to on-chaingetDappPublicKey(_secret)organizer - RSVP privacy — guest + secret committed via
UserAddress; only the hash is stored inpersistentCommithashedPartyGoers - Privacy boundary — calls
checkInthenreceiveUnshielded(nativeToken(), entryFee)— guest address becomes publiccheckedInParty.insert(disclose(address)) - Organizer becomes public — calls
claimFeesto organizer'ssendUnshielded(...)UserAddress - is a developer assertion — it marks values safe for public domains; it does not perform the disclosure itself
disclose() - output is safe on ledger without
persistentCommit— sufficiently random salt (disclose()) required_secret - Use +
createUnprovenDeployTx— notsubmitTxAsync(hangs on preprod)deployContract() - Wrap with patched
indexerPublicDataProvider(GraphQLqueryContractStatebug)offset: null - Entry fee is on ledger but cast to
Uint<16>for unshielded ops; 1 NIGHT = 1_000_000 StarsUint<128> - Persist organizer/attendee in
_secret— losing it means losing auth for that rolelocalStorage - Network: this template targets everywhere (wallet connect, indexer, proof server). If your deployment target is
preprod, changepreviewinDEFAULT_NETWORK(§9) — that's the single source of truth; nothing else should hardcode a network string.lib/midnight.ts
Workflow
- Contract — write/scaffold (§5); compile with
private-party.compactyarn compile - Understand privacy boundary — private RSVP → public check-in (unshielded) → public payout
- Providers — (§9,
createConnectedSession)lib/midnight.ts - Deploy — organizer passes to constructor
(partySize, entryFee, organizerSecret) - RSVP — attendees call before party starts
rsvp(userAddress, secret) - Start — organizer calls when ready (or auto when list full →
startParty(secret))READY - Check in — RSVP'd guests call + pay entry fee (crosses boundary)
checkIn(address, secret) - Close — organizer if not everyone checked in; or auto when full
closeEntry(secret) - Claim — organizer after doors closed
claimFees(organizerAddress, secret) - UI — role picker, party status panel, indexer polling for public state
1) Project Structure
private-party-dapp/
├── package.json
├── next.config.mjs
├── postcss.config.mjs
├── lib/
│ ├── isomorphic-ws-fix.mjs
│ ├── midnight.ts # session, patched provider, hex helpers
│ ├── party.ts # deploy, circuits, decode state
│ ├── address.ts # Bech32 → UserAddress bytes
│ └── secret.ts # crypto.getRandomValues + localStorage
├── app/
│ ├── layout.tsx
│ ├── globals.css
│ └── party/
│ └── PartyClient.tsx # organizer + attendee UI
├── contract/
│ ├── package.json
│ └── src/
│ ├── private-party.compact
│ ├── index.ts # CompiledContract.withVacantWitnesses
│ └── managed/private-party/ # compiler output (gitignored)
├── scripts/
│ └── sync-zk-assets.mjs # → public/zk/private-party/
└── public/zk/private-party/ # keys + zkir (gitignored until sync)example-private-party/
├── contract/private-party.compact
├── src/test/party.test.ts # vitest: Alice organizer, Bob/Claire guests
├── compose.yml # node + indexer + proof-server
└── package.json # yarn test:local2) Prerequisites
node --version # 22+ for vitest harness; 20+ for Next.js frontend
docker --version # optional local devnet tests
curl --proto '=https' --tlsv1.2 -sSf \
https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh
source $HOME/.local/bin/envDEFAULT_NETWORKlib/midnight.ts3) Root package.json
package.json{
"name": "private-party-dapp",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --webpack",
"build": "npm run sync:assets && next build --webpack",
"compact": "npm run compact --prefix contract",
"sync:assets": "node scripts/sync-zk-assets.mjs",
"postinstall": "npm install --prefix contract"
},
"dependencies": {
"@midnight-ntwrk/compact-runtime": "0.16.0",
"@midnight-ntwrk/compact-js": "4.0.4",
"@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-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/wallet-sdk-address-format": "3.1.0",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0",
"stream-browserify": "^3.0.0",
"@tailwindcss/postcss": "^4.0.0",
"tailwindcss": "^4.0.0"
}
}4) contract/package.json
contract/package.json{
"name": "@private-party/contract",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"compact": "compact compile src/private-party.compact src/managed/private-party"
},
"dependencies": {
"@midnight-ntwrk/compact-runtime": "0.16.0"
}
}npm installcontract/@midnight-ntwrk/compact-runtimeContractMaintenanceAuthoritycontract/package.jsoncompactnpm run compact
# → contract/src/managed/private-party/{contract,keys,zkir}/
npm run sync:assets
# → public/zk/private-party/rsvpstartPartycheckIncloseEntryclaimFees5) contract/src/private-party.compact
contract/src/private-party.compactrsvpstartPartycheckIncloseEntryclaimFeespragma language_version 0.23;
import CompactStandardLibrary;
export enum PartyState {
NOT_STARTED,
READY,
STARTED,
DOORS_CLOSED,
FEES_CLAIMED
}
export sealed ledger organizer: Bytes<32>;
export sealed ledger maxListSize: Uint<16>;
export sealed ledger entryFee: Uint<16>;
export ledger partyState: PartyState;
export ledger hashedPartyGoers: Set<Bytes<32>>;
export ledger checkedInParty: Set<UserAddress>;
constructor (partySize: Uint<16>, fee: Uint<16>, _secret: Bytes<32>) {
assert(partySize > 0, "The party size must be greater than zero");
assert(fee > 0, "Fee must be greater than zero");
const pubKey = getDappPublicKey(_secret);
organizer = disclose(pubKey);
entryFee = disclose(fee);
maxListSize = disclose(partySize);
partyState = PartyState.NOT_STARTED;
}
export circuit rsvp(_address: UserAddress, _secret: Bytes<32>): [] {
const pubKey = getDappPublicKey(_secret);
assert(pubKey != organizer, "Organizer cannot RSVP to the party");
assert(partyState == PartyState.NOT_STARTED, "The party has already started");
assert(hashedPartyGoers.size() < maxListSize, "The list is full");
const commitHash = commitAddress(_secret, _address.bytes);
assert(!hashedPartyGoers.member(commitHash), "You are already on the list");
hashedPartyGoers.insert(commitHash);
if (hashedPartyGoers.size() == maxListSize) {
partyState = PartyState.READY;
}
}
export circuit startParty(_secret: Bytes<32>): [] {
const pubKey = getDappPublicKey(_secret);
assert(organizer == pubKey, "Only the organizer can start the party");
assert(partyState == PartyState.READY || partyState == PartyState.NOT_STARTED,
"The party is not in the correct state for this operation");
partyState = PartyState.STARTED;
}
export circuit checkIn(address: UserAddress, _secret: Bytes<32>): [] {
assert(partyState == PartyState.STARTED, "The party has not been started. Call the party police");
assert(checkedInParty.size() < hashedPartyGoers.size(), "All guests have already checked in");
const commitHash = commitAddress(_secret, address.bytes);
assert(hashedPartyGoers.member(commitHash), "You are not on the list");
assert(!checkedInParty.member(disclose(address)), "You have already checked in");
// Privacy boundary: unshielded payment makes guest address public
receiveUnshielded(nativeToken(), entryFee as Uint<128>);
checkedInParty.insert(disclose(address));
if (checkedInParty.size() == maxListSize) {
partyState = PartyState.DOORS_CLOSED;
}
}
export circuit closeEntry(_secret: Bytes<32>): [] {
const pubKey = getDappPublicKey(_secret);
assert(organizer == pubKey, "Only organizer can close the doors");
assert(partyState == PartyState.STARTED, "Party in wrong state");
partyState = PartyState.DOORS_CLOSED;
}
export circuit claimFees(address: UserAddress, _secret: Bytes<32>): [] {
const pubKey = getDappPublicKey(_secret);
assert(organizer == pubKey, "You are not the organizer");
assert(partyState == PartyState.DOORS_CLOSED, "The doors are not yet closed");
assert(checkedInParty.size() > 0, "No fees to claim");
const totalCollected = checkedInParty.size() * entryFee;
assert(unshieldedBalanceGte(nativeToken(), totalCollected), "Contract balance wrong");
sendUnshielded(
nativeToken(),
disclose(totalCollected) as Uint<128>,
right<ContractAddress, UserAddress>(disclose(address))
);
partyState = PartyState.FEES_CLAIMED;
}
circuit commitAddress(_address: Bytes<32>, _secret: Bytes<32>): Bytes<32> {
return persistentCommit<Bytes<32>>(_address, _secret);
}
circuit getDappPublicKey(_secret: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "private-party:pk:"), _secret]);
}Privacy model summary
| Phase | Attendee identity | On-chain data |
|---|---|---|
| RSVP | Private | Commitment hash in |
| Before check-in | Private | Hash count visible; no addresses |
| Check-in | Public | |
| Claim fees | Organizer public | |
Always-public Compact domains
- Ledger fields (after or safe commits)
disclose() - Circuit return values from exported circuits
- Contract-to-contract calls
- Unshielded token transfers (,
receiveUnshielded)sendUnshielded
6) contract/src/index.ts
contract/src/index.tswithVacantWitnessesawait import()CompiledContractimport { CompiledContract } from '@midnight-ntwrk/compact-js';
import { sampleSigningKey, ContractState } from '@midnight-ntwrk/compact-runtime';
let _contractModule: any = null;
let _compiledContract: any = null;
let _ledgerFn: any = null;
export async function getCompiledContract(): Promise<any> {
if (!_compiledContract) {
if (!_contractModule) {
_contractModule = await import('./managed/private-party/contract/index.js');
}
_compiledContract = CompiledContract.make(
'private-party',
_contractModule.Contract,
);
_compiledContract = CompiledContract.withVacantWitnesses(_compiledContract);
}
return _compiledContract;
}
export async function getLedger(): Promise<any> {
if (!_ledgerFn) {
if (!_contractModule) {
_contractModule = await import('./managed/private-party/contract/index.js');
}
_ledgerFn = _contractModule.ledger;
}
return _ledgerFn;
}
export { sampleSigningKey, ContractState };- imported from
CompiledContract(not@midnight-ntwrk/compact-js) — the two packages both export something with this name and only one matches the runtime'scompact-runtime.withVacantWitnesses - Lazy singleton (,
getCompiledContract) — avoids the dual-instance WASM identity bug.getLedger - and
ContractStatere-exported fromsampleSigningKeyso callers never import that package directly (keeps the single-copy guarantee from §4).compact-runtime
7) lib/address.ts
lib/address.tsUserAddress{ bytes: Uint8Array }UserAddressBytes<32>Uint8Arrayimport { MidnightBech32m, UnshieldedAddress } from '@midnight-ntwrk/wallet-sdk-address-format';
export function bech32ToUserAddress(bech32: string, networkId: string): { bytes: Uint8Array } {
const parsed = MidnightBech32m.parse(bech32).decode(UnshieldedAddress, networkId);
return { bytes: new Uint8Array(parsed.data) };
}8) lib/secret.ts
lib/secret.tsimport { toHex, fromHex } from './midnight';
export function generateSecret(): Uint8Array {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return bytes;
}
export function saveSecret(role: 'organizer' | 'attendee', contractAddress: string, secret: Uint8Array) {
localStorage.setItem(`private-party:${role}:${contractAddress}`, toHex(secret));
}
export function loadSecret(role: 'organizer' | 'attendee', contractAddress: string): Uint8Array | null {
const hex = localStorage.getItem(`private-party:${role}:${contractAddress}`);
return hex ? fromHex(hex) : null;
}9) lib/midnight.ts
— full implementation
lib/midnight.tsimport { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { FetchZkConfigProvider } from '@midnight-ntwrk/midnight-js-fetch-zk-config-provider';
export type NetworkName = 'preprod' | 'preview';
// Single source of truth for network — change this, not any hardcoded
// 'preprod' string elsewhere in the app, if you target a different network.
export const DEFAULT_NETWORK: NetworkName = 'preprod';
export interface NetworkConfig {
networkId: NetworkName;
indexerUri: string;
indexerWsUri: string;
proofServerUri: string;
}
const NETWORKS: Record<NetworkName, NetworkConfig> = {
preprod: {
networkId: 'preprod',
indexerUri: 'https://indexer.preprod.midnight.network/api/v1/graphql',
indexerWsUri: 'wss://indexer.preprod.midnight.network/api/v1/graphql/ws',
proofServerUri: 'https://proof-server.preprod.midnight.network',
},
preview: {
networkId: 'preview',
indexerUri: 'https://indexer.preview.midnight.network/api/v1/graphql',
indexerWsUri: 'wss://indexer.preview.midnight.network/api/v1/graphql/ws',
proofServerUri: 'https://proof-server.preview.midnight.network',
},
};
export interface ConnectedSession {
unshieldedAddress: string;
config: NetworkConfig;
providers: {
walletProvider: any;
zkConfigProvider: any;
privateStateProvider: any;
publicDataProvider: any;
};
}
export function toHex(bytes: Uint8Array): string {
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
}
export function fromHex(hex: string): Uint8Array {
const clean = hex.startsWith('0x') ? hex.slice(2) : hex;
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(clean.substr(i * 2, 2), 16);
}
return out;
}
// Wallet APIs return coin public keys in inconsistent shapes depending on
// wallet version — normalize all of them to a 32-byte Uint8Array.
export function coinPublicKeyToBytes(cpk: unknown): Uint8Array {
if (cpk instanceof Uint8Array) return cpk;
if (typeof cpk === 'string') return fromHex(cpk);
if (Array.isArray(cpk)) return new Uint8Array(cpk);
if (cpk && typeof cpk === 'object' && 'bytes' in (cpk as any)) {
return coinPublicKeyToBytes((cpk as any).bytes);
}
throw new Error(`Unrecognized coin public key shape: ${JSON.stringify(cpk)}`);
}
export async function detectWallet(): Promise<any> {
const w = (window as any).midnight;
if (!w) throw new Error('No Midnight-compatible wallet found in window.midnight');
if (w['1am']) return w['1am'];
const first = Object.values(w)[0];
if (!first) throw new Error('window.midnight is present but empty');
return first;
}
function createPrivateStateProvider() {
const store = new Map<string, unknown>();
const contractAddresses = new Map<string, string>();
const signingKeys = new Map<string, unknown>();
return {
async get(id: string) { return store.get(id) ?? null; },
async set(id: string, state: unknown) { store.set(id, state); },
async remove(id: string) { store.delete(id); },
async setContractAddress(address: string) { contractAddresses.set('__current__', address); },
async getContractAddress() { return contractAddresses.get('__current__') ?? null; },
async setSigningKey(contractAddress: string, key: unknown) { signingKeys.set(contractAddress, key); },
async getSigningKey(contractAddress: string) { return signingKeys.get(contractAddress) ?? null; },
};
}
// The default indexer provider throws on `offset: null` against the
// preprod/preview indexer (see §15). This wraps it with a raw fetch that
// omits the offending field.
function createPatchedPublicDataProvider(queryUrl: string, subscriptionUrl: string) {
const base = indexerPublicDataProvider(queryUrl, subscriptionUrl);
return {
...base,
async queryContractState(contractAddress: string) {
const res = await fetch(queryUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
query ContractState($address: String!) {
contractAction(address: $address) {
state
}
}
`,
variables: { address: contractAddress },
}),
});
const json = await res.json();
const state = json?.data?.contractAction?.state;
return state ? { state } : null;
},
};
}
export async function pollForState(
queryUrl: string,
contractAddress: string,
{ retries = 20, delayMs = 1500 }: { retries?: number; delayMs?: number } = {},
): Promise<string> {
for (let i = 0; i < retries; i++) {
const res = await fetch(queryUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
query ContractState($address: String!) {
contractAction(address: $address) { state }
}
`,
variables: { address: contractAddress },
}),
});
const json = await res.json();
const state = json?.data?.contractAction?.state;
if (state) return state as string;
await new Promise((r) => setTimeout(r, delayMs));
}
throw new Error(`Timed out waiting for contract state at ${contractAddress}`);
}
export async function createConnectedSession(
walletApi: any,
zkPath: string,
network: NetworkName = DEFAULT_NETWORK,
): Promise<ConnectedSession> {
const config = NETWORKS[network];
const state = await walletApi.state();
const unshieldedAddress: string = state.address ?? state.unshieldedAddress;
const walletProvider = {
coinPublicKey: coinPublicKeyToBytes(state.coinPublicKey),
balanceTx: (tx: unknown) => walletApi.balanceTransaction(tx),
submitTx: (tx: unknown) => walletApi.submitTransaction(tx),
};
const zkConfigProvider = new FetchZkConfigProvider(zkPath);
const privateStateProvider = createPrivateStateProvider();
const publicDataProvider = createPatchedPublicDataProvider(config.indexerUri, config.indexerWsUri);
return {
unshieldedAddress,
config,
providers: { walletProvider, zkConfigProvider, privateStateProvider, publicDataProvider },
};
}10) lib/party.ts
lib/party.tsgetCompiledContract()import { createUnprovenDeployTx, submitCallTxAsync, submitTxAsync } from '@midnight-ntwrk/midnight-js-contracts';
import { getCompiledContract, getLedger, sampleSigningKey, ContractState } from '../contract/src/index';
import type { ConnectedSession } from './midnight';
import { fromHex, pollForState } from './midnight';
import { bech32ToUserAddress } from './address';
const PRIVATE_STATE_ID = 'PrivatePartyState';
export const ZK_PATH = '/zk/private-party';
const PARTY_STATE_NAMES = [
'NOT_STARTED',
'READY',
'STARTED',
'DOORS_CLOSED',
'FEES_CLAIMED',
] as const;
function setSize(value: unknown): number {
if (typeof value === 'number') return value;
if (value && typeof value === 'object' && 'size' in value) {
const size = (value as { size: unknown }).size;
if (typeof size === 'function') return Number((size as () => number)());
if (typeof size === 'number') return size;
}
return 0;
}
export async function deployParty(
session: ConnectedSession,
partySize: number,
entryFeeStars: number,
organizerSecret: Uint8Array,
): Promise<string> {
const cc = await getCompiledContract();
const deployTxData = await (createUnprovenDeployTx as any)(
{
zkConfigProvider: session.providers.zkConfigProvider,
walletProvider: session.providers.walletProvider,
},
{
compiledContract: cc,
args: [BigInt(partySize), BigInt(entryFeeStars), organizerSecret],
privateStateId: PRIVATE_STATE_ID,
initialPrivateState: {},
signingKey: sampleSigningKey(),
},
);
const contractAddress = deployTxData.public.contractAddress;
await (submitTxAsync as any)(session.providers, { unprovenTx: deployTxData.private.unprovenTx });
await session.providers.privateStateProvider.setContractAddress(contractAddress);
await session.providers.privateStateProvider.set(PRIVATE_STATE_ID, {});
await session.providers.privateStateProvider.setSigningKey(
contractAddress,
deployTxData.private.signingKey,
);
return contractAddress;
}
async function call(
session: ConnectedSession,
contractAddress: string,
circuitId: string,
args: unknown[],
) {
const cc = await getCompiledContract();
await (submitCallTxAsync as any)(session.providers, {
compiledContract: cc,
contractAddress,
circuitId,
args,
privateStateId: PRIVATE_STATE_ID,
});
}
export const rsvp = (session: ConnectedSession, contractAddress: string, userAddress: { bytes: Uint8Array }, attendeeSecret: Uint8Array) =>
call(session, contractAddress, 'rsvp', [userAddress, attendeeSecret]);
export const startParty = (session: ConnectedSession, contractAddress: string, organizerSecret: Uint8Array) =>
call(session, contractAddress, 'startParty', [organizerSecret]);
export const checkIn = (session: ConnectedSession, contractAddress: string, userAddress: { bytes: Uint8Array }, attendeeSecret: Uint8Array) =>
call(session, contractAddress, 'checkIn', [userAddress, attendeeSecret]);
export const closeEntry = (session: ConnectedSession, contractAddress: string, organizerSecret: Uint8Array) =>
call(session, contractAddress, 'closeEntry', [organizerSecret]);
export const claimFees = (session: ConnectedSession, contractAddress: string, organizerAddress: { bytes: Uint8Array }, organizerSecret: Uint8Array) =>
call(session, contractAddress, 'claimFees', [organizerAddress, organizerSecret]);
export async function decodePartyState(stateHex: string) {
const contractState = ContractState.deserialize(fromHex(stateHex));
const ledger = await getLedger();
const l = ledger(contractState.data) as any;
const stateIdx = Number(l.partyState);
return {
partyState: PARTY_STATE_NAMES[stateIdx] ?? `UNKNOWN(${stateIdx})`,
partyStateIndex: stateIdx,
maxListSize: Number(l.maxListSize),
entryFee: Number(l.entryFee),
rsvpCount: setSize(l.hashedPartyGoers),
checkedInCount: setSize(l.checkedInParty),
};
}
export async function fetchPartyState(queryUrl: string, contractAddress: string) {
const hex = await pollForState(queryUrl, contractAddress);
return decodePartyState(hex);
}
export function userAddressFromSession(session: ConnectedSession) {
return bech32ToUserAddress(session.unshieldedAddress, session.config.networkId);
}- use
argsforBigInt(...)fields, not plain numbers.Uint<16> - args are passed as raw
Bytes<32>, not wrapped inUint8Array.{ bytes: ... }args require theUserAddresswrapper.{ bytes } - is resolved via
ledger()from the contract's own compiled module — never a static top-level import (breaks the lazy-singleton guarantee from §6).getLedger() - handles both
setSize()(property) and.size(method) since compiled.size()ledger fields vary by SDK version.Set
11) Frontend — app/party/PartyClient.tsx
app/party/PartyClient.tsx'use client';
import { useCallback, useEffect, useState } from 'react';
import {
checkIn,
claimFees,
closeEntry,
deployParty,
fetchPartyState,
rsvp,
startParty,
userAddressFromSession,
ZK_PATH,
} from '@/lib/party';
import { createConnectedSession, detectWallet, DEFAULT_NETWORK, type ConnectedSession } from '@/lib/midnight';
import { generateSecret, loadSecret, saveSecret } from '@/lib/secret';
type Role = 'organizer' | 'attendee';
export default function PartyClient() {
const [session, setSession] = useState<ConnectedSession | null>(null);
const [role, setRole] = useState<Role>('attendee');
const [contractAddress, setContractAddress] = useState('');
const [partySize, setPartySize] = useState('2');
const [entryFee, setEntryFee] = useState('5');
const [status, setStatus] = useState<Awaited<ReturnType<typeof fetchPartyState>> | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!session || !contractAddress) return;
try {
setStatus(await fetchPartyState(session.config.indexerUri, contractAddress));
} catch (e) {
setError(String(e));
}
}, [session, contractAddress]);
useEffect(() => { void refresh(); }, [refresh]);
async function guard(fn: () => Promise<void>) {
setBusy(true);
setError(null);
try {
await fn();
await refresh();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
const onConnect = () => guard(async () => {
const wallet = await detectWallet();
const api = await wallet.connect(DEFAULT_NETWORK);
setSession(await createConnectedSession(api, ZK_PATH));
});
const onDeploy = () => guard(async () => {
if (!session) return;
const secret = generateSecret();
const addr = await deployParty(session, Number(partySize), Number(entryFee), secret);
setContractAddress(addr);
saveSecret('organizer', addr, secret);
});
const onRsvp = () => guard(async () => {
if (!session || !contractAddress) return;
let secret = loadSecret('attendee', contractAddress);
if (!secret) {
secret = generateSecret();
saveSecret('attendee', contractAddress, secret);
}
await rsvp(session, contractAddress, userAddressFromSession(session), secret);
});
const onStartParty = () => guard(async () => {
if (!session || !contractAddress) return;
const secret = loadSecret('organizer', contractAddress);
if (!secret) throw new Error('No organizer secret found for this contract in this browser');
await startParty(session, contractAddress, secret);
});
const onCheckIn = () => guard(async () => {
if (!session || !contractAddress) return;
const secret = loadSecret('attendee', contractAddress);
if (!secret) throw new Error('RSVP first — no attendee secret found for this contract');
await checkIn(session, contractAddress, userAddressFromSession(session), secret);
});
const onCloseEntry = () => guard(async () => {
if (!session || !contractAddress) return;
const secret = loadSecret('organizer', contractAddress);
if (!secret) throw new Error('No organizer secret found for this contract in this browser');
await closeEntry(session, contractAddress, secret);
});
const onClaimFees = () => guard(async () => {
if (!session || !contractAddress) return;
const secret = loadSecret('organizer', contractAddress);
if (!secret) throw new Error('No organizer secret found for this contract in this browser');
await claimFees(session, contractAddress, userAddressFromSession(session), secret);
});
return (
<div className="mx-auto max-w-xl p-6 text-zinc-100">
<h1 className="text-2xl font-semibold">Private Party</h1>
<p className="mt-1 text-muted">Attendees stay private until check-in pays unshielded NIGHT.</p>
{!session ? (
<button type="button" onClick={onConnect} disabled={busy}
className="mt-4 rounded bg-brand px-4 py-2 hover:bg-brand-hover disabled:opacity-50">
Connect Wallet
</button>
) : (
<div className="mt-4 space-y-4">
<p className="text-sm text-muted">Connected: {session.unshieldedAddress}</p>
<label className="block">
Role
<select value={role} onChange={(e) => setRole(e.target.value as Role)}
className="ml-2 rounded border border-border bg-surface px-2 py-1 text-zinc-100">
<option value="organizer">Organizer</option>
<option value="attendee">Attendee</option>
</select>
</label>
<label className="block">
Contract address
<input value={contractAddress} onChange={(e) => setContractAddress(e.target.value.trim())}
placeholder="mn_contract1..."
className="mt-1 w-full rounded border border-border bg-surface px-2 py-1 text-zinc-100 placeholder:text-zinc-500" />
</label>
{status ? (
<p className="rounded border border-border bg-card p-3 text-sm">
State: {status.partyState} · RSVPs: {status.rsvpCount}/{status.maxListSize} ·
Checked in: {status.checkedInCount} · Fee: {status.entryFee} Stars
</p>
) : null}
{role === 'organizer' && !contractAddress ? (
<div className="space-y-2 rounded border border-border bg-card p-3">
<label className="block">Max guests
<input value={partySize} onChange={(e) => setPartySize(e.target.value)}
className="ml-2 w-20 rounded border border-border bg-surface px-2 py-1 text-zinc-100" />
</label>
<label className="block">Entry fee (Stars)
<input value={entryFee} onChange={(e) => setEntryFee(e.target.value)}
className="ml-2 w-24 rounded border border-border bg-surface px-2 py-1 text-zinc-100" />
</label>
<button type="button" onClick={onDeploy} disabled={busy}
className="rounded bg-brand px-3 py-1.5 hover:bg-brand-hover disabled:opacity-50">
Deploy Party
</button>
</div>
) : null}
{role === 'organizer' && contractAddress ? (
<div className="flex flex-wrap gap-2">
<button type="button" onClick={onStartParty} disabled={busy}
className="rounded bg-brand px-3 py-1.5 hover:bg-brand-hover disabled:opacity-50">Start party</button>
<button type="button" onClick={onCloseEntry} disabled={busy}
className="rounded bg-card border border-border px-3 py-1.5 hover:bg-[#1e1e38] disabled:opacity-50">Close doors</button>
<button type="button" onClick={onClaimFees} disabled={busy}
className="rounded bg-success px-3 py-1.5 hover:opacity-90 disabled:opacity-50">Claim fees</button>
</div>
) : null}
{role === 'attendee' && contractAddress ? (
<div className="flex flex-wrap gap-2">
<button type="button" onClick={onRsvp} disabled={busy}
className="rounded bg-brand px-3 py-1.5 hover:bg-brand-hover disabled:opacity-50">RSVP</button>
<button type="button" onClick={onCheckIn} disabled={busy}
className="rounded bg-danger px-3 py-1.5 hover:opacity-90 disabled:opacity-50">
Check in (pays {status?.entryFee ?? '?'} Stars — becomes public)
</button>
</div>
) : null}
</div>
)}
{error ? <p role="alert" className="mt-4 rounded border border-danger bg-[#3a1414] p-2 text-sm text-red-300">{error}</p> : null}
</div>
);
}12) ZK Asset Sync — scripts/sync-zk-assets.mjs
scripts/sync-zk-assets.mjsimport { cpSync, mkdirSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const src = join(root, 'contract/src/managed/private-party');
const dest = join(root, 'public/zk/private-party');
rmSync(dest, { recursive: true, force: true });
mkdirSync(dest, { recursive: true });
for (const dir of ['keys', 'zkir']) {
cpSync(join(src, dir), join(dest, dir), { recursive: true });
}http://localhost:3000/zk/private-party/keys/rsvp.prover13) next.config.mjs
next.config.mjs/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config) => {
config.experiments = { ...config.experiments, asyncWebAssembly: true, topLevelAwait: true };
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
net: false,
tls: false,
child_process: false,
stream: 'stream-browserify',
};
config.resolve.alias = {
...config.resolve.alias,
'isomorphic-ws': join(process.cwd(), 'lib/isomorphic-ws-fix.mjs'),
};
return config;
},
// Static generation tries to resolve the SDK's Node deps and can hang the build.
images: { unoptimized: true },
};
import { join } from 'node:path';
export default nextConfig;14) postcss.config.mjs
and app/globals.css
postcss.config.mjsapp/globals.css// postcss.config.mjs
export default {
plugins: { '@tailwindcss/postcss': {} },
};/* app/globals.css */
@import "tailwindcss";
@theme {
--color-brand: #7c3aed;
--color-brand-hover: #6d28d9;
--color-surface: #1a1a2e;
--color-card: #16213e;
--color-border: #2a2a4a;
--color-muted: #9ca3af;
--color-success: #16a34a;
--color-danger: #dc2626;
}/* app/layout.tsx */
import './globals.css';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-[#0f0f23] text-zinc-100 antialiased">{children}</body>
</html>
);
}bg-surface text-zinc-100<input>15) Local Devnet Tests (Official Repo)
git clone git@github.com:midnightntwrk/example-private-party.git
cd example-private-party
yarn install
yarn compile
yarn env:up # Docker: node + indexer + proof-server
yarn test:local # vitest party.test.ts- Deploy contract (Alice organizer)
- Bob RSVPs privately
- Alice (organizer) rejected from RSVP
- Claire RSVPs
- Bob rejected from startParty
- Alice starts party
- Bob checks in → becomes public
- Bob rejected from closeEntry
- Alice closes doors
- Alice claimFees → NIGHT balance increases by
checkedInCount * entryFee - Hard-way deploy test
/src/test/party.test.tsFluentWalletBuilder16) End-to-End Browser Flow
1. npm install && npm run compact && npm run sync:assets
2. npm run dev
3. Organizer: Connect 1AM → Deploy (max 2 guests, fee 5 Stars) → copy contract address
4. Attendee (other browser/wallet): Connect → paste address → RSVP
5. Organizer: Start party (or wait until RSVP list full → READY)
6. Attendee: Check in (wallet pays 5 Stars unshielded — address now public on ledger)
7. Organizer: Close doors (if needed) → Claim fees to unshielded address
8. Poll indexer — verify checkedInCount and partyState transitions17) Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Organizer secret used for RSVP | Use separate attendee secret |
| Wrong secret or address at check-in | Same |
| RSVP after startParty | RSVP only in NOT_STARTED |
| Wrong organizer secret | Reload from |
| Fee mismatch or partial check-ins | Verify |
| Bech32 passed as bytes | Use |
| Deploy hangs 30–120s | Used | Use |
GraphQL | Default indexer provider | Use patched |
| ZK 404 | Assets not synced | |
| Lost organizer secret | No recovery on-chain | Redeploy contract; store secret in localStorage |
| | Import |
| Dual | Never |
| Static import fails in browser | Lazy |
| Passed | Wrap in |
| Confusing it with | Raw |
| Passed hex string directly | |
| Called a static import instead of the contract's own export | |
| Missing Node polyfills | |
| Missing | |
| Tailwind v4 not applied | Missing PostCSS config | |
| Input/select text invisible on dark bg | Browser default white input bg | |
| | |
| Static generation tries to resolve SDK's Node deps | |
| Wallet rejects connection / wrong chain | App and wallet on different networks | Both must match |
18) Agent Checklist
- Write with all six exported circuits + helper circuits (§5)
private-party.compact - Compile; sync ZK assets to
public/zk/private-party/ - from
CompiledContract.withVacantWitnesses, not@midnight-ntwrk/compact-jscompact-runtime - Lazy /
getCompiledContract()singleton (§6)getLedger() - copied in full from §9 — do not improvise the provider layer
lib/midnight.ts - and
next.config.mjscopied in full from §13–14postcss.config.mjs - Constructor args:
[BigInt(partySize), BigInt(entryFeeStars), rawUint8Array] - Decode unshielded Bech32 via
wallet-sdk-address-format - Store organizer/attendee secrets in per contract address
localStorage - UI explains the privacy boundary before the check-in button (see copy in §11)
- Never run inside
npm installcontract/ - Confirm in
DEFAULT_NETWORKmatches the target network before demoinglib/midnight.ts
19) Extensions
Shielded entry fees
checkInreceiveUnshieldedtoken-transfers/Multi-party testing UI
checkedInPartyHeadless CI
party.test.tsexample-hello-world/20) Related Skills
| Next step | Skill |
|---|---|
| Wallet connect only | |
| Unshielded token flows | |
| Payment vault pattern | |
| Privacy audit | |
| Compact language reference | |
| Local vitest harness | |