mcpField
E
example-private-party-dapp

example-private-party-dapp

github.com/kali-decoder开发18130 次安装1 天前更新
代码仓库

关于 example-private-party-dapp

用于指导构建 Midnight Network 私密派对 DApp 的开发技能,面向 Compact 和 Next.js 开发者。它提供可运行模板,并说明 RSVP 身份隐私如何在签到和付款时转为公开。

主要功能

  1. 01生成完整 Next.js 合约与界面模板
  2. 02实现私密 RSVP 与公开签到
  3. 03涵盖部署、状态轮询和费用领取
  4. 04提供 Compact 测试与故障排查参考

适用场景

  1. 01从模板搭建派对 RSVP DApp
  2. 02学习 Compact 合约的隐私边界
  3. 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 部署挂起、GraphQL offset: 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(修复 GraphQL offset: null 问题)
  • 账本上的入场费为 Uint<16>,但在非屏蔽操作中会转换为 Uint<128>;1 NIGHT = 1_000_000 Stars
  • 将组织者/参与者的 _secret 持久化到 localStorage — 丢失密钥即意味着失去该角色的身份验证能力
  • 网络:此模板所有配置均针对 preprod(钱包连接、索引器、证明服务器)。如果部署目标为 preview,请在 lib/midnight.ts(§9)中更改 DEFAULT_NETWORK — 这是唯一可信来源;其他地方都不应硬编码网络字符串。

工作流

帮助用户时,请遵循以下顺序:

  1. 合约 — 编写/搭建 private-party.compact(§5);使用 yarn compile 编译
  2. 了解隐私边界 — 私密 RSVP → 公开签到(非屏蔽)→ 公开付款
  3. 提供程序 — createConnectedSession(§9,lib/midnight.ts)
  4. 部署 — 组织者将 (partySize, entryFee, organizerSecret) 传入构造函数
  5. RSVP — 参加者在聚会开始前调用 rsvp(userAddress, secret)
  6. 开始 — 组织者准备就绪后调用 startParty(secret)(或名单满员时自动进入 READY)
  7. 签到 — 已 RSVP 的访客调用 checkIn(address, secret) 并支付入场费(跨越隐私边界)
  8. 关闭 — 如果并非所有人都已签到,组织者调用 closeEntry(secret);或在满员时自动关闭
  9. 领取 — 关门后,组织者调用 claimFees(organizerAddress, secret)
  10. 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 项测试):

  1. 部署合约(Alice 为组织者)
  2. Bob 私密 RSVP
  3. Alice(组织者)被拒绝 RSVP
  4. Claire RSVP
  5. Bob 被拒绝调用 startParty
  6. Alice 开始派对
  7. Bob 签到 → 转为公开
  8. Bob 被拒绝调用 closeEntry
  9. Alice 关门
  10. Alice 调用 claimFees → NIGHT 余额增加 checkedInCount * entryFee
  11. 通过繁琐方式部署的测试

阅读 /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 RSVPRSVP 时使用了组织者密钥使用单独的参与者密钥
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/

评论

支持 Markdown