关于 example-private-party-dapp
用于指导构建 Midnight Network 私密派对 DApp 的开发技能,面向 Compact 和 Next.js 开发者。它提供可运行模板,并说明 RSVP 身份隐私如何在签到和付款时转为公开。
主要功能
- 01生成完整 Next.js 合约与界面模板
- 02实现私密 RSVP 与公开签到
- 03涵盖部署、状态轮询和费用领取
- 04提供 Compact 测试与故障排查参考
适用场景
- 01从模板搭建派对 RSVP DApp
- 02学习 Compact 合约的隐私边界
- 03实现 Midnight 钱包连接与合约交互
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ kali-decoder/ midnight-skills --skill example-private-party-dapp安装后在 Claude Code 中输入/example-private-party-dapp确认已加载。
详细介绍
Midnight Network 私密派对 DApp
私密派对合约可让组织者收集 RSVP,同时让参与者身份保持私密,直到宾客签到并以未屏蔽 NIGHT支付入场费。这笔付款就是隐私边界——在 Midnight 上,未屏蔽的代币流转始终是公开的。
**可运行模板:**复制 templates/private-party-dapp/,即可获得完整的 Next.js 项目(合约 + UI)。安装 1AM 钱包 后,运行 npm install && npm run compact && npm run sync:assets && npm run dev。
官方参考:github.com/midnightntwrk/example-private-party — Compact 教程 + vitest 测试框架(在 Docker devnet 上运行 yarn test:local)。
使用此技能的两种方式:
- 脚手架模式(默认) — 用户想要一个可运行的 dApp。请逐字使用以下所有代码块;它们是经过测试的参考实现。不要重新推导。
- 教程模式 — 用户明确想通过亲自编写来学习 Compact。在这种情况下,按电路逐一讲解
private-party.compact(§5),让他们自己输入;但仍使用代码块搭建周边的 TS/UI——这些并非教学重点。
此技能的产出:
contract/—private-party.compact(无 witnesses)+ 编译脚本app/party/— Next.js 客户端 UI(组织者部署/开始/关闭/领取;参与者 RSVP/签到)lib/midnight.ts— 钱包会话 + 已修补的索引器提供程序(完整实现见 §9)lib/party.ts— 部署、rsvp、startParty、checkIn、closeEntry、claimFees、账本解码lib/address.ts— 将 Bech32 未屏蔽地址转换为{ bytes: Uint8Array },用于UserAddress电路参数lib/secret.ts— 在localStorage中生成/存储 32 字节 DApp 密钥public/zk/private-party/— 从合约构建同步的 ZK 证明资产
共享参考资料(规范的提供程序 + 故障排查——不要在提示词中重复):
references/gotchas.md— preprod 部署挂起、GraphQLoffset: null、ZK 资产路径references/versions.json— 固定的@midnight-ntwrk/*版本
主要参考资料:
example-locker-dapp//templates/locker-dapp/— Next.js + 1AM、底层部署/调用example-payment-dapp/— 未屏蔽的receiveUnshielded/sendUnshielded模式example-hello-world/— vitest + Docker devnet 测试框架(官方仓库提供测试脚本)compact/—disclose()、persistentCommit、persistentHash、Set、密封账本、枚举security/— 隐私边界检查清单、哪些内容会在何时公开token-transfers/— 未屏蔽 NIGHT 单位(Stars)、Bech32 地址解码 关键架构说明:- 无需见证者 — 调用方身份验证通过电路私有的
_secret完成:调用getDappPublicKey(_secret),并与链上的organizer进行比较 - RSVP 隐私 — 将访客的
UserAddress和密钥通过persistentCommit提交;只有哈希会存储在hashedPartyGoers中 - 隐私边界 —
checkIn会调用receiveUnshielded(nativeToken(), entryFee),然后调用checkedInParty.insert(disclose(address))— 访客地址会变为公开信息 - 组织者身份会公开 —
claimFees会调用sendUnshielded(...),将款项发送至组织者的UserAddress disclose()是开发者断言 — 它标记可在公共域中安全使用的值;它本身不会执行披露操作persistentCommit的输出无需disclose()即可安全存储在账本上 — 需要足够随机的盐值(_secret)- 使用
createUnprovenDeployTx+submitTxAsync— 不要使用deployContract()(在 preprod 上会卡住) - 使用修补过的
queryContractState封装indexerPublicDataProvider(修复 GraphQLoffset: null问题) - 账本上的入场费为
Uint<16>,但在非屏蔽操作中会转换为Uint<128>;1 NIGHT = 1_000_000 Stars - 将组织者/参与者的
_secret持久化到localStorage— 丢失密钥即意味着失去该角色的身份验证能力 - 网络:此模板所有配置均针对
preprod(钱包连接、索引器、证明服务器)。如果部署目标为preview,请在lib/midnight.ts(§9)中更改DEFAULT_NETWORK— 这是唯一可信来源;其他地方都不应硬编码网络字符串。
工作流
帮助用户时,请遵循以下顺序:
- 合约 — 编写/搭建
private-party.compact(§5);使用yarn compile编译 - 了解隐私边界 — 私密 RSVP → 公开签到(非屏蔽)→ 公开付款
- 提供程序 —
createConnectedSession(§9,lib/midnight.ts) - 部署 — 组织者将
(partySize, entryFee, organizerSecret)传入构造函数 - RSVP — 参加者在聚会开始前调用
rsvp(userAddress, secret) - 开始 — 组织者准备就绪后调用
startParty(secret)(或名单满员时自动进入READY) - 签到 — 已 RSVP 的访客调用
checkIn(address, secret)并支付入场费(跨越隐私边界) - 关闭 — 如果并非所有人都已签到,组织者调用
closeEntry(secret);或在满员时自动关闭 - 领取 — 关门后,组织者调用
claimFees(organizerAddress, secret) - UI — 角色选择器、聚会状态面板,以及用于查询公开状态的索引器轮询
1) 项目结构
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:local
2) 先决条件
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/env
浏览器:1AM 钱包,网络设置需与 lib/midnight.ts 中的 DEFAULT_NETWORK 一致(默认为 preprod),并持有用于支付入场费的 tNIGHT。
3) 根目录 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
{
"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"
}
}
不要在 contract/ 中运行 npm install — 其中额外安装一份 @midnight-ntwrk/compact-runtime 会导致双 WASM 实例错误(ContractMaintenanceAuthority 身份错误)。所有依赖项都应位于根目录;contract/package.json 仅用于 compact 编译脚本。
编译:
npm run compact
# → contract/src/managed/private-party/{contract,keys,zkir}/
npm run sync:assets
# → public/zk/private-party/
预期电路:rsvp、startParty、checkIn、closeEntry、claimFees。
5) contract/src/private-party.compact
脚手架模式下,请原样使用此内容。教程模式下,请按以下顺序逐个构建电路:账本声明 → 构造函数 → rsvp → startParty → checkIn → closeEntry → claimFees → 辅助电路。
pragma 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]);
}
隐私模型摘要
| 阶段 | 参加者身份 | 链上数据 |
|---|---|---|
| RSVP | 私密 | 仅 hashedPartyGoers 中的承诺哈希 |
| 签到前 | 私密 | 哈希数量可见;无地址 |
| 签到 | 公开 | receiveUnshielded + checkedInParty 中的地址 |
| 领取费用 | 组织者身份公开 | 向组织者地址调用 sendUnshielded |
始终公开的 Compact 域
- 账本字段(在调用
disclose()或安全提交之后) - 导出电路的返回值
- 合约间调用
- 未屏蔽的代币转账(
receiveUnshielded、sendUnshielded)
6) contract/src/index.ts
没有见证项——请使用 withVacantWitnesses。使用延迟 await import() 模式,以避免 SSR 问题,并确保从正确的模块解析 CompiledContract。
import { 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 };
要点:
- 从
@midnight-ntwrk/compact-js导入CompiledContract(而不是compact-runtime)——这两个包都导出了同名内容,但只有其中一个与运行时的withVacantWitnesses匹配。 - 延迟单例(
getCompiledContract、getLedger)——可避免 WASM 双实例身份错误。 - 从
compact-runtime重新导出ContractState和sampleSigningKey,这样调用方就无需直接导入该包(可确保 §4 中的单副本保证)。
7) lib/address.ts
为 UserAddress 电路参数解码 Bech32 未屏蔽地址。UserAddress 必须使用 { bytes: Uint8Array } 格式——但 Bytes<32> 参数不需要;这类参数接收原始 Uint8Array。
import { 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
import { 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 — 完整实现
这是在实际使用中最容易导致故障的文件(参见 §15)。这里会给出完整内容,而不是要点——请勿自行改写。
import { 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
使用 getCompiledContract()(延迟单例)和适用于 Compact 0.23+ 的正确参数格式。
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);
}
关键细节:
args中的Uint<16>字段使用BigInt(...),而不是普通数字。Bytes<32>参数直接传入原始Uint8Array,不要将其包装在{ bytes: ... }中。UserAddress参数则需要{ bytes }包装。- 通过合约自身编译模块中的
getLedger()解析ledger()——绝不要在顶层静态导入(否则会破坏 §6 中的延迟单例保证)。 setSize()同时处理.size(属性)和.size()(方法),因为编译后的Set账本字段因 SDK 版本而异。
11) 前端 — 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 资产同步 — scripts/sync-zk-assets.mjs
import { 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.prover 返回 200。
13) next.config.mjs
必需——否则 webpack 无法解析 Midnight SDK 的 Node 内置模块和 WASM。
/** @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 和 app/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) 本地 Devnet 测试(官方仓库)
git clone [email protected]: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
预期流程(11 项测试):
- 部署合约(Alice 为组织者)
- Bob 私密 RSVP
- Alice(组织者)被拒绝 RSVP
- Claire RSVP
- Bob 被拒绝调用
startParty - Alice 开始派对
- Bob 签到 → 转为公开
- Bob 被拒绝调用
closeEntry - Alice 关门
- Alice 调用
claimFees→ NIGHT 余额增加checkedInCount * entryFee - 通过繁琐方式部署的测试
阅读 /src/test/party.test.ts,了解使用 FluentWalletBuilder 的 MidnightJS 提供程序模式。
16) 端到端浏览器流程
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 transitions
17) 故障排查
| 症状 | 原因 | 修复方法 |
|---|---|---|
Organizer cannot RSVP | RSVP 时使用了组织者密钥 | 使用单独的参与者密钥 |
You are not on the list | 签到时密钥或地址错误 | 使用与 RSVP 相同的 _secret 和 UserAddress |
The party has already started | 在 startParty 之后 RSVP | 仅在 NOT_STARTED 状态下 RSVP |
Only the organizer can start | 组织者密钥错误 | 从 localStorage 重新加载或重新部署 |
Contract balance wrong | 费用不匹配或部分签到 | 验证 entryFee * checkedInCount |
Invalid character 'm' at position 0 | 将 Bech32 作为字节传入 | 使用 bech32ToUserAddress() |
| 部署卡住 30–120s | 使用了 deployContract() | 使用 createUnprovenDeployTx + submitTxAsync |
GraphQL offset: null | 使用了默认索引器提供程序 | 使用经过修补的 queryContractState(§9) |
| ZK 404 | 资产未同步 | npm run sync:assets |
| 丢失组织者密钥 | 链上无法恢复 | 重新部署合约;将密钥存储在 localStorage 中 |
CompiledContract.withVacantWitnesses TypeScript 错误 | compact-runtime 与 compact-js 不匹配 | 从 @midnight-ntwrk/compact-js 导入 CompiledContract |
ContractMaintenanceAuthority WASM 身份错误 | 存在两个 compact-runtime 实例(合约本地 node_modules + 根目录) | 切勿在 contract/ 内运行 npm install(§4) |
makeCompiledContract 不是函数 | 静态导入在浏览器中失败 | 使用延迟 await import(...) + 单例(§6) |
构造函数参数中的 bigint 类型不匹配 | 需要 bigint 时传入了 number | 对 Uint<16> 字段使用 BigInt(value) 包装 |
Bytes<32> 包装在 { bytes: ... } 中导致类型不匹配 | 与 UserAddress 混淆 | Bytes<32> 使用原始 Uint8Array;只有 UserAddress 使用 { bytes } |
ContractState.deserialize 参数类型不匹配 | 直接传入了十六进制字符串 | ContractState.deserialize(fromHex(action.state))——需要 Uint8Array |
ledger() 不是函数 | 调用了静态导入,而不是合约自身的导出 | 使用 contract/src/index.ts 中的 getLedger()(§6) |
webpack 中找不到 isomorphic-ws | 缺少 Node polyfill | 在 next.config.mjs 中配置 fallback + alias(§13) |
pipeline is not a function | 缺少 stream polyfill | 在 next.config.mjs 中设置 stream: 'stream-browserify'(§13) |
| 未应用 Tailwind v4 | 缺少 PostCSS 配置 | 使用包含 @tailwindcss/postcss 的 postcss.config.mjs(§14) |
| 深色背景上的输入框/选择框文字不可见 | 浏览器默认使用白色输入框背景 | 为所有输入框设置 bg-surface text-zinc-100(§14) |
Set 账本字段上的 size() 不是函数 | 某些 SDK 版本中 .size 是属性,另一些版本中是方法 | setSize() 辅助函数可兼容两种情况(§10) |
npm run build 卡住 | 静态生成尝试解析 SDK 的 Node 依赖 | 在 next.config.mjs 中设置 images: { unoptimized: true }(§13) |
| 钱包拒绝连接/链错误 | 应用和钱包使用了不同的网络 | 两者都必须与 lib/midnight.ts 中的 DEFAULT_NETWORK 一致(§9)——只修改一处配置,不要散落修改字符串 |
18) Agent 检查清单
- 编写
private-party.compact,包含全部六个导出电路和辅助电路(§5) - 编译;将 ZK 资产同步到
public/zk/private-party/ -
CompiledContract.withVacantWitnesses从@midnight-ntwrk/compact-js导入,而非compact-runtime - 延迟初始化
getCompiledContract()/getLedger()单例(§6) - 完整复制 §9 中的
lib/midnight.ts——不要自行修改提供程序层 - 完整复制 §13–14 中的
next.config.mjs和postcss.config.mjs - 构造函数参数:
[BigInt(partySize), BigInt(entryFeeStars), rawUint8Array] - 通过
wallet-sdk-address-format解码未屏蔽的 Bech32 - 按合约地址将组织者/参与者密钥存储在
localStorage中 - 在签到按钮之前,UI 需说明隐私边界(参见 §11 中的文案)
- 切勿在
contract/内运行npm install - 演示前确认
lib/midnight.ts中的DEFAULT_NETWORK与目标网络一致
19) 扩展
隐私保护的入场费
为在付款过程中保护参会者隐私,将 checkIn 重构为使用隐私保护代币,而不是 receiveUnshielded——请参阅教程结尾和 token-transfers/ 技能。
多方测试 UI
添加“复制邀请链接”功能,并在链接中加入合约地址查询参数;跨过边界后,显示公开的 checkedInParty 地址。
无头 CI
按照 example-hello-world/ 的 Docker compose 模式,将官方 party.test.ts 移植到模板 monorepo 中。
20)相关技能
| 后续步骤 | 技能 |
|---|---|
| 仅连接钱包 | react-wallet-connector/ |
| 非隐私保护代币流程 | token-transfers/ |
| 支付金库模式 | example-payment-dapp/ |
| 隐私审计 | security/ |
| 精简语言参考 | compact/ |
| 本地 vitest 测试框架 | example-hello-world/ |