example-private-party-dapp

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Midnight Network Private Party DApp

Midnight Network 私有派对DApp

A private party contract lets an organizer collect RSVPs while attendee identities stay private until guests check in and pay the entry fee in unshielded NIGHT. That payment is the privacy boundary — unshielded token flows are always public on Midnight.
Runnable template: Copy
templates/private-party-dapp/
for a complete Next.js project (contract + UI). Run
npm install && npm run compact && npm run sync:assets && npm run dev
after installing the 1AM wallet.
Official reference:
github.com/midnightntwrk/example-private-party
— Compact tutorial + vitest harness (
yarn test:local
on Docker devnet).
Two ways to use this skill:
  • 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
    private-party.compact
    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.
What this skill produces:
  • contract/
    private-party.compact
    (no witnesses) + compile scripts
  • app/party/
    — Next.js client UI (organizer deploy/start/close/claim + attendee RSVP/check-in)
  • lib/midnight.ts
    — wallet session + patched indexer provider (full implementation in §9)
  • lib/party.ts
    — deploy,
    rsvp
    ,
    startParty
    ,
    checkIn
    ,
    closeEntry
    ,
    claimFees
    , ledger decode
  • lib/address.ts
    — Bech32 unshielded address →
    { bytes: Uint8Array }
    for
    UserAddress
    circuit args
  • lib/secret.ts
    — generate/store 32-byte DApp secrets in
    localStorage
  • public/zk/private-party/
    — ZK proving assets synced from contract build
Shared references (canonical provider + troubleshooting — do not duplicate in prompts):
  • references/gotchas.md
    — preprod deploy hangs, GraphQL
    offset: null
    , ZK asset paths
  • references/versions.json
    — pinned
    @midnight-ntwrk/*
    versions
Primary references:
  • example-locker-dapp/
    /
    templates/locker-dapp/
    — Next.js + 1AM, low-level deploy/call
  • example-payment-dapp/
    — unshielded
    receiveUnshielded
    /
    sendUnshielded
    patterns
  • example-hello-world/
    — vitest + Docker devnet test harness (test script provided in official repo)
  • compact/
    disclose()
    ,
    persistentCommit
    ,
    persistentHash
    ,
    Set
    , sealed ledger, enums
  • security/
    — privacy boundary checklist, what becomes public and when
  • token-transfers/
    — unshielded NIGHT units (Stars), Bech32 address decoding
Key architecture notes:
  • No witnesses — caller auth uses circuit-private
    _secret
    getDappPublicKey(_secret)
    compared to on-chain
    organizer
  • RSVP privacy — guest
    UserAddress
    + secret committed via
    persistentCommit
    ; only the hash is stored in
    hashedPartyGoers
  • Privacy boundary
    checkIn
    calls
    receiveUnshielded(nativeToken(), entryFee)
    then
    checkedInParty.insert(disclose(address))
    — guest address becomes public
  • Organizer becomes public
    claimFees
    calls
    sendUnshielded(...)
    to organizer's
    UserAddress
  • disclose()
    is a developer assertion
    — it marks values safe for public domains; it does not perform the disclosure itself
  • persistentCommit
    output is safe on ledger without
    disclose()
    — sufficiently random salt (
    _secret
    ) required
  • Use
    createUnprovenDeployTx
    +
    submitTxAsync
    — not
    deployContract()
    (hangs on preprod)
  • Wrap
    indexerPublicDataProvider
    with patched
    queryContractState
    (GraphQL
    offset: null
    bug)
  • Entry fee is
    Uint<16>
    on ledger but cast to
    Uint<128>
    for unshielded ops; 1 NIGHT = 1_000_000 Stars
  • Persist organizer/attendee
    _secret
    in
    localStorage
    — losing it means losing auth for that role
  • Network: this template targets
    preprod
    everywhere
    (wallet connect, indexer, proof server). If your deployment target is
    preview
    , change
    DEFAULT_NETWORK
    in
    lib/midnight.ts
    (§9) — that's the single source of truth; nothing else should hardcode a network string.

私有派对合约允许组织者收集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测试网执行
yarn test:local
)。
两种使用方式:
  • 脚手架模式(默认)——用户需要可运行的dApp。直接使用下方所有代码块,它们是经过测试的参考实现,无需重新推导。
  • 教程模式——用户明确希望通过编写代码学习Compact。这种情况下,需逐步讲解
    private-party.compact
    的每个电路(第5节),让用户自行编写,但仍需从代码块中搭建周边的TS/UI代码——这些内容不属于教学重点。
本教程产出内容:
  • contract/
    ——
    private-party.compact
    (无见证者)+编译脚本
  • 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
    —— 预生产环境部署卡顿、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测试网测试套件(官方仓库提供测试脚本)
  • 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()
    (在预生产环境会卡顿)
  • 用修补后的
    queryContractState
    包装
    indexerPublicDataProvider
    (解决GraphQL
    offset: null
    bug)
  • 入场费在账本上是
    Uint<16>
    类型,但在未屏蔽操作中需转换为
    Uint<128>
    ;1 NIGHT = 1_000_000 Stars
  • 将组织者/参会者的
    _secret
    持久化在
    localStorage
    中——丢失密钥意味着失去对应角色的权限
  • 网络:本模板默认目标为
    preprod
    环境
    (钱包连接、索引器、证明服务器)。若部署目标为
    preview
    ,修改
    lib/midnight.ts
    (第9节)中的
    DEFAULT_NETWORK
    ——这是唯一的数据源;其他地方不应硬编码网络字符串。

Workflow

工作流程

When helping the user, follow this sequence:
  1. Contract — write/scaffold
    private-party.compact
    (§5); compile with
    yarn compile
  2. Understand privacy boundary — private RSVP → public check-in (unshielded) → public payout
  3. Providers
    createConnectedSession
    (§9,
    lib/midnight.ts
    )
  4. Deploy — organizer passes
    (partySize, entryFee, organizerSecret)
    to constructor
  5. RSVP — attendees call
    rsvp(userAddress, secret)
    before party starts
  6. Start — organizer calls
    startParty(secret)
    when ready (or auto when list full →
    READY
    )
  7. Check in — RSVP'd guests call
    checkIn(address, secret)
    + pay entry fee (crosses boundary)
  8. Close — organizer
    closeEntry(secret)
    if not everyone checked in; or auto when full
  9. Claim — organizer
    claimFees(organizerAddress, secret)
    after doors closed
  10. UI — role picker, party status panel, indexer polling for public state

协助用户时,请遵循以下步骤:
  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) Project Structure

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)
Optional test harness (official repo — not in browser template):
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

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) Prerequisites

2) 前置要求

bash
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
Browser: 1AM wallet, network set to match
DEFAULT_NETWORK
in
lib/midnight.ts
(preprod by default), funded with tNIGHT for entry fees.

bash
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) Root
package.json

3) 根目录
package.json

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"
  }
}

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

4)
contract/package.json

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"
  }
}
Do not run
npm install
inside
contract/
— a second copy of
@midnight-ntwrk/compact-runtime
there causes the dual-WASM-instance bug (
ContractMaintenanceAuthority
identity error). All deps live at root;
contract/package.json
exists only for the
compact
compile script.
Compile:
bash
npm run compact
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实例bug(
ContractMaintenanceAuthority
身份错误)。所有依赖均在根目录;
contract/package.json
仅用于
compact
编译脚本。
编译:
bash
npm run compact

→ contract/src/managed/private-party/{contract,keys,zkir}/

→ contract/src/managed/private-party/{contract,keys,zkir}/

npm run sync:assets
npm run sync:assets

→ public/zk/private-party/

→ public/zk/private-party/


Expected circuits: `rsvp`, `startParty`, `checkIn`, `closeEntry`, `claimFees`.

---

预期生成的电路:`rsvp`, `startParty`, `checkIn`, `closeEntry`, `claimFees`。

---

5)
contract/src/private-party.compact

5)
contract/src/private-party.compact

In scaffold mode, use this exactly. In tutorial mode, build it up circuit by circuit in this order: ledger declarations → constructor →
rsvp
startParty
checkIn
closeEntry
claimFees
→ helper circuits.
compact
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
startParty
checkIn
closeEntry
claimFees
→辅助电路。
compact
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]);
}

Privacy model summary

隐私模型总结

PhaseAttendee identityOn-chain data
RSVPPrivateCommitment hash in
hashedPartyGoers
only
Before check-inPrivateHash count visible; no addresses
Check-inPublic
receiveUnshielded
+ address in
checkedInParty
Claim feesOrganizer public
sendUnshielded
to organizer address
阶段参会者身份链上数据
RSVP私密
hashedPartyGoers
中的承诺哈希
签到前私密仅可见哈希数量;无地址信息
签到公开
receiveUnshielded
+
checkedInParty
中的地址
提现组织者公开
sendUnshielded
至组织者地址

Always-public Compact domains

始终公开的Compact域

  • Ledger fields (after
    disclose()
    or safe commits)
  • Circuit return values from exported circuits
  • Contract-to-contract calls
  • Unshielded token transfers (
    receiveUnshielded
    ,
    sendUnshielded
    )

  • 账本字段(经过
    disclose()
    或安全承诺后)
  • 导出电路的返回值
  • 合约间调用
  • 未屏蔽代币转账
    receiveUnshielded
    ,
    sendUnshielded

6)
contract/src/index.ts

6)
contract/src/index.ts

No witnesses — use
withVacantWitnesses
. Use lazy
await import()
pattern
to avoid SSR issues and ensure
CompiledContract
is resolved from the correct module.
typescript
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 };
Key points:
  • CompiledContract
    imported from
    @midnight-ntwrk/compact-js
    (not
    compact-runtime
    ) — the two packages both export something with this name and only one matches the runtime's
    withVacantWitnesses
    .
  • Lazy singleton (
    getCompiledContract
    ,
    getLedger
    ) — avoids the dual-instance WASM identity bug.
  • ContractState
    and
    sampleSigningKey
    re-exported from
    compact-runtime
    so callers never import that package directly (keeps the single-copy guarantee from §4).

无见证者——使用
withVacantWitnesses
使用延迟
await import()
模式
,避免SSR问题,并确保
CompiledContract
从正确模块解析。
typescript
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 };
核心要点:
  • CompiledContract
    @midnight-ntwrk/compact-js
    导入(而非
    compact-runtime
    )——两个包都导出同名内容,但只有前者匹配运行时的
    withVacantWitnesses
  • 延迟单例模式(
    getCompiledContract
    ,
    getLedger
    )——避免双实例WASM身份bug。
  • ContractState
    sampleSigningKey
    compact-runtime
    重新导出,调用者无需直接导入该包(保持第4节的单副本保证)。

7)
lib/address.ts

7)
lib/address.ts

Decode Bech32 unshielded addresses for
UserAddress
circuit args. The
{ bytes: Uint8Array }
format is required for
UserAddress
not for
Bytes<32>
args, which take a raw
Uint8Array
.
typescript
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) };
}

解码Bech32未屏蔽地址,用于
UserAddress
电路参数。
{ bytes: Uint8Array }
格式是
UserAddress
的要求——不适用于
Bytes<32>
参数,后者接受原始
Uint8Array
typescript
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

8)
lib/secret.ts

typescript
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;
}

typescript
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
— full implementation

9)
lib/midnight.ts
—— 完整实现

This is the file that causes the most real-world breakage (see §15). It is given in full here rather than as bullets — do not improvise it.
typescript
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 },
  };
}

该文件是实际开发中最容易出问题的部分(见第15节)。此处提供完整代码,请勿自行编写。
typescript
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

10)
lib/party.ts

Uses
getCompiledContract()
(lazy singleton) and the correct arg format for Compact 0.23+.
typescript
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);
}
Key details:
  • args
    use
    BigInt(...)
    for
    Uint<16>
    fields, not plain numbers.
  • Bytes<32>
    args are passed as raw
    Uint8Array
    , not wrapped in
    { bytes: ... }
    .
    UserAddress
    args require the
    { bytes }
    wrapper.
  • ledger()
    is resolved via
    getLedger()
    from the contract's own compiled module — never a static top-level import (breaks the lazy-singleton guarantee from §6).
  • setSize()
    handles both
    .size
    (property) and
    .size()
    (method) since compiled
    Set
    ledger fields vary by SDK version.

使用
getCompiledContract()
(延迟单例)和Compact 0.23+的正确参数格式。
typescript
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, userAddress: { bytes: Uint8Array }, organizerSecret: Uint8Array) =>
  call(session, contractAddress, 'claimFees', [userAddress, 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 }
    包裹。
  • ledger()
    通过合约自身编译模块的
    getLedger()
    解析——切勿使用静态顶层导入(破坏第6节的延迟单例保证)。
  • setSize()
    处理
    .size
    (属性)和
    .size()
    (方法),因为编译后的
    Set
    账本字段因SDK版本而异。

11) Frontend —
app/party/PartyClient.tsx

11) 前端 ——
app/party/PartyClient.tsx

Full component — no placeholder handlers.
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>
  );
}

完整组件——无占位符处理函数。
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

12) ZK资产同步 ——
scripts/sync-zk-assets.mjs

javascript
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 });
}
Verify:
http://localhost:3000/zk/private-party/keys/rsvp.prover
returns 200.

javascript
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

13)
next.config.mjs

Required — without this, webpack fails to resolve the Midnight SDK's Node built-ins and WASM.
javascript
/** @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;

必填项——若无此配置,webpack无法解析Midnight SDK的Node内置模块和WASM。
javascript
/** @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

14)
postcss.config.mjs
app/globals.css

javascript
// postcss.config.mjs
export default {
  plugins: { '@tailwindcss/postcss': {} },
};
css
/* 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;
}
tsx
/* 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>
  );
}
No light theme — everything is dark surface/card/border/muted. Inputs and selects must use
bg-surface text-zinc-100
explicitly (a raw
<input>
defaults to a white background that renders invisible text on this theme).

javascript
// postcss.config.mjs
export default {
  plugins: { '@tailwindcss/postcss': {} },
};
css
/* 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;
}
tsx
/* 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)

15) 本地测试网测试(官方仓库)

bash
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
Expected flow (11 tests):
  1. Deploy contract (Alice organizer)
  2. Bob RSVPs privately
  3. Alice (organizer) rejected from RSVP
  4. Claire RSVPs
  5. Bob rejected from startParty
  6. Alice starts party
  7. Bob checks in → becomes public
  8. Bob rejected from closeEntry
  9. Alice closes doors
  10. Alice claimFees → NIGHT balance increases by
    checkedInCount * entryFee
  11. Hard-way deploy test
Read
/src/test/party.test.ts
for MidnightJS provider patterns with
FluentWalletBuilder
.

bash
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
预期流程(11个测试用例):
  1. 部署合约(Alice作为组织者)
  2. Bob私密RSVP
  3. Alice(组织者)被拒绝RSVP
  4. Claire RSVP
  5. Bob被拒绝启动派对
  6. Alice启动派对
  7. Bob签到→身份变为公开
  8. Bob被拒绝关闭入场
  9. Alice关闭入场
  10. Alice提现→NIGHT余额增加
    checkedInCount * entryFee
  11. 复杂部署测试
查看
/src/test/party.test.ts
了解使用
FluentWalletBuilder
的MidnightJS提供者模式。

16) End-to-End Browser Flow

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

1. npm install && npm run compact && npm run sync:assets
2. npm run dev
3. 组织者:连接1AM钱包→部署(最多2位宾客,费用5 Stars)→复制合约地址
4. 参会者(其他浏览器/钱包):连接钱包→粘贴地址→RSVP
5. 组织者:启动派对(或等待RSVP名单满员→READY状态)
6. 参会者:签到(钱包支付5 Stars未屏蔽代币——地址现在在账本上公开)
7. 组织者:关闭入场(若需要)→提现至未屏蔽地址
8. 轮询索引器——验证checkedInCount和partyState状态转换

17) Troubleshooting

17) 故障排查

SymptomCauseFix
Organizer cannot RSVP
Organizer secret used for RSVPUse separate attendee secret
You are not on the list
Wrong secret or address at check-inSame
_secret
+
UserAddress
as RSVP
The party has already started
RSVP after startPartyRSVP only in NOT_STARTED
Only the organizer can start
Wrong organizer secretReload from
localStorage
or redeploy
Contract balance wrong
Fee mismatch or partial check-insVerify
entryFee * checkedInCount
Invalid character 'm' at position 0
Bech32 passed as bytesUse
bech32ToUserAddress()
Deploy hangs 30–120sUsed
deployContract()
Use
createUnprovenDeployTx
+
submitTxAsync
GraphQL
offset: null
Default indexer providerUse patched
queryContractState
(§9)
ZK 404Assets not synced
npm run sync:assets
Lost organizer secretNo recovery on-chainRedeploy contract; store secret in localStorage
CompiledContract.withVacantWitnesses
TypeScript error
compact-runtime
vs
compact-js
mismatch
Import
CompiledContract
from
@midnight-ntwrk/compact-js
ContractMaintenanceAuthority
WASM identity error
Dual
compact-runtime
instances (contract-local
node_modules
+ root)
Never
npm install
inside
contract/
(§4)
makeCompiledContract
not a function
Static import fails in browserLazy
await import(...)
+ singleton (§6)
bigint
type mismatch on constructor args
Passed
number
where
bigint
expected
Wrap in
BigInt(value)
for
Uint<16>
fields
Bytes<32>
wrapped in
{ bytes: ... }
mismatch
Confusing it with
UserAddress
Raw
Uint8Array
for
Bytes<32>
;
{ bytes }
only for
UserAddress
ContractState.deserialize
argument type mismatch
Passed hex string directly
ContractState.deserialize(fromHex(action.state))
— needs
Uint8Array
ledger()
is not a function
Called a static import instead of the contract's own export
getLedger()
from
contract/src/index.ts
(§6)
isomorphic-ws
not found in webpack
Missing Node polyfills
next.config.mjs
fallback + alias (§13)
pipeline is not a function
Missing
stream
polyfill
stream: 'stream-browserify'
in
next.config.mjs
(§13)
Tailwind v4 not appliedMissing PostCSS config
postcss.config.mjs
with
@tailwindcss/postcss
(§14)
Input/select text invisible on dark bgBrowser default white input bg
bg-surface text-zinc-100
on all inputs (§14)
size()
not a function on Set ledger fields
.size
is a property in some SDK versions, method in others
setSize()
helper handles both (§10)
npm run build
hangs
Static generation tries to resolve SDK's Node deps
images: { unoptimized: true }
in
next.config.mjs
(§13)
Wallet rejects connection / wrong chainApp and wallet on different networksBoth must match
DEFAULT_NETWORK
in
lib/midnight.ts
(§9) — change one config, not scattered strings

症状原因修复方案
Organizer cannot RSVP
使用组织者密钥进行RSVP使用独立的参会者密钥
You are not on the list
签到时密钥或地址错误使用与RSVP时相同的
_secret
+
UserAddress
The party has already started
派对启动后进行RSVP仅在NOT_STARTED状态下RSVP
Only the organizer can start
组织者密钥错误
localStorage
重新加载或重新部署合约
Contract balance wrong
费用不匹配或部分签到验证
entryFee * checkedInCount
Invalid character 'm' at position 0
将Bech32地址作为字节传递使用
bech32ToUserAddress()
部署卡顿30–120秒使用了
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
类型不匹配
传入
number
而非
bigint
Uint<16>
字段使用
BigInt(value)
包裹
Bytes<32>
参数包裹在
{ bytes: ... }
中导致不匹配
混淆了
Bytes<32>
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 polyfills
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 Checklist

18) 代理检查清单

  • Write
    private-party.compact
    with all six exported circuits + helper circuits (§5)
  • Compile; sync ZK assets to
    public/zk/private-party/
  • CompiledContract.withVacantWitnesses
    from
    @midnight-ntwrk/compact-js
    , not
    compact-runtime
  • Lazy
    getCompiledContract()
    /
    getLedger()
    singleton (§6)
  • lib/midnight.ts
    copied in full from §9 — do not improvise the provider layer
  • next.config.mjs
    and
    postcss.config.mjs
    copied in full from §13–14
  • Constructor args:
    [BigInt(partySize), BigInt(entryFeeStars), rawUint8Array]
  • Decode unshielded Bech32 via
    wallet-sdk-address-format
  • Store organizer/attendee secrets in
    localStorage
    per contract address
  • UI explains the privacy boundary before the check-in button (see copy in §11)
  • Never run
    npm install
    inside
    contract/
  • Confirm
    DEFAULT_NETWORK
    in
    lib/midnight.ts
    matches the target network before demoing

  • 编写包含所有6个导出电路+辅助电路的
    private-party.compact
    (第5节)
  • 编译合约;将ZK资产同步至
    public/zk/private-party/
  • @midnight-ntwrk/compact-js
    而非
    compact-runtime
    导入
    CompiledContract.withVacantWitnesses
  • 使用延迟
    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) Extensions

19) 扩展功能

Shielded entry fees

屏蔽入场费

To keep attendees private through payment, rework
checkIn
to use shielded tokens instead of
receiveUnshielded
— see tutorial conclusion and
token-transfers/
skill.
若要在支付过程中保持参会者隐私,需重新设计
checkIn
以使用屏蔽代币替代
receiveUnshielded
——详见教程结论和
token-transfers/
技能。

Multi-party testing UI

多方测试UI

Add "copy invite link" with contract address query param; show public
checkedInParty
addresses after boundary crossed.
添加“复制邀请链接”功能,包含合约地址查询参数;跨越隐私边界后显示公开的
checkedInParty
地址。

Headless CI

无头CI

Port official
party.test.ts
into the template monorepo using
example-hello-world/
Docker compose pattern.

使用
example-hello-world/
的Docker compose模式,将官方
party.test.ts
移植到模板单仓库中。

20) Related Skills

20) 相关技能

Next stepSkill
Wallet connect only
react-wallet-connector/
Unshielded token flows
token-transfers/
Payment vault pattern
example-payment-dapp/
Privacy audit
security/
Compact language reference
compact/
Local vitest harness
example-hello-world/
下一步技能
仅钱包连接
react-wallet-connector/
未屏蔽代币流转
token-transfers/
支付 vault 模式
example-payment-dapp/
隐私审计
security/
Compact语言参考
compact/
本地vitest测试套件
example-hello-world/