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. 01Next.jsのコントラクトとUIを生成
  2. 02非公開RSVPと公開チェックインを実装
  3. 03デプロイ、状態ポーリング、手数料請求に対応
  4. 04Compactのテストとトラブル対処を案内

活用シーン

  1. 01テンプレートからパーティーRSVP DAppを構築
  2. 02Compactコントラクトのプライバシー境界を学習
  3. 03Midnightウォレットとコントラクトの連携を実装

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/kali-decoder/midnight-skills --skill example-private-party-dapp

インストール後、Claude Code で/example-private-party-dappを入力して読み込まれたか確認します。

詳細

Midnight Network Private Party DApp

プライベートパーティーコントラクトを使うと、主催者は出席者の本人確認情報を非公開のまま出欠確認を集められます。本人確認情報が公開されるのは、ゲストがチェックインして、シールドされていない NIGHTで参加費を支払ったときです。この支払いがプライバシーの境界となります。シールドされていないトークンのフローは、Midnight では常に公開されます。

実行可能なテンプレート: 完成済みの Next.js プロジェクト(コントラクト+UI)を使うには、templates/private-party-dapp/ をコピーします。1AM wallet をインストールした後、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(witness なし)+コンパイルスクリプト
  • app/party/ — Next.js クライアント UI(主催者のデプロイ/開始/終了/請求+出席者の出欠確認/チェックイン)
  • lib/midnight.ts — ウォレットセッション+修正済みインデクサープロバイダー(完全な実装は§9)
  • lib/party.ts — デプロイ、rsvp、startParty、checkIn、closeEntry、claimFees、台帳のデコード
  • lib/address.ts — Bech32 のシールドされていないアドレスを、UserAddress サーキット引数用の { bytes: Uint8Array } に変換
  • lib/secret.ts — 32 バイトの DApp シークレットを生成して localStorage に保存
  • 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 はオーガナイザーの UserAddress に対して sendUnshielded(...) を呼び出します
  • disclose() は開発者によるアサーションです — 値をパブリックドメインで安全なものとしてマークします。開示自体を実行するものではありません
  • persistentCommit の出力は disclose() なしでも台帳上で安全です — 十分にランダムなソルト(_secret)が必要です
  • createUnprovenDeployTx と submitTxAsync を使用してください。deployContract() は使用しないでください(preprod でハングします)
  • indexerPublicDataProvider はパッチ適用済みの queryContractState でラップしてください(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 のコピーがもう 1 つできると、WASM インスタンス二重化のバグ(ContractMaintenanceAuthority の ID エラー)が発生します。すべての依存関係はルートに置きます。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

スキャフォールドモードでは、これをそのまま使用してください。チュートリアルモードでは、次の順に回路を 1 つずつ構築してください:台帳宣言 → コンストラクター → 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]);
}
プライバシーモデルの概要
フェーズ参加者の IDオンチェーンデータ
RSVP非公開hashedPartyGoers 内のコミットメントハッシュのみ
チェックイン前非公開ハッシュ数は可視、アドレスなし
チェックイン公開receiveUnshielded と checkedInParty 内のアドレス
手数料請求オーガナイザーは公開オーガナイザーのアドレスへの sendUnshielded
常に公開されるCompactドメイン
  • 台帳フィールド(disclose() の実行後、または安全なコミット後)
  • エクスポートされた回路の戻り値
  • コントラクト間の呼び出し
  • シールドされていないトークンの送金(receiveUnshielded、sendUnshielded)

6) contract/src/index.ts

witnessはありません。withVacantWitnesses を使用してください。SSRの問題を回避し、CompiledContract が正しいモジュールから解決されるように、遅延 await import() パターンを使用してください。

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 は compact-runtime ではなく、@midnight-ntwrk/compact-js からインポートします。両方のパッケージが同じ名前のものをエクスポートしていますが、ランタイムの withVacantWitnesses と一致するのは一方だけです。
  • 遅延シングルトン(getCompiledContract、getLedger)を使用して、WASMの二重インスタンスによるIDの不具合を回避します。
  • ContractState と sampleSigningKey は compact-runtime から再エクスポートし、呼び出し元がそのパッケージを直接インポートしないようにします(§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);
}

主な詳細:

  • Uint<16> フィールドの args には、通常の数値ではなく BigInt(...) を使用します。
  • Bytes<32> 引数には生の Uint8Array を渡し、{ bytes: ... } でラップしません。UserAddress 引数には { bytes } ラッパーが必要です。
  • ledger() は、静的なトップレベルインポートではなく、コントラクト独自のコンパイル済みモジュールから getLedger() を通じて解決します(静的インポートは§6の遅延シングルトンの保証を損ないます)。
  • コンパイル済みの Set 台帳フィールドはSDKのバージョンによって異なるため、setSize() は .size(プロパティ)と .size()(メソッド)の両方に対応します。

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. 手動でデプロイするテスト

FluentWalletBuilder を使ったMidnightJSプロバイダーのパターンについては、/src/test/party.test.ts を参照してください。


16) エンドツーエンドのブラウザーフロー

1. npm install && npm run compact && npm run sync:assets
2. npm run dev
3. Organizer: Connect 1AM → Deploy (max 2 guests, fee 5 Stars) → copy contract address
4. Attendee (other browser/wallet): Connect → paste address → RSVP
5. Organizer: Start party (or wait until RSVP list full → READY)
6. Attendee: Check in (wallet pays 5 Stars unshielded — address now public on ledger)
7. Organizer: Close doors (if needed) → Claim fees to unshielded address
8. Poll indexer — verify checkedInCount and partyState transitions

17) トラブルシューティング

症状原因対処方法
Organizer cannot RSVP主催者用のシークレットを出欠登録に使用しています参加者用のシークレットを別途使用してください
You are not on the listチェックイン時のシークレットまたはアドレスが間違っています出欠登録時と同じ _secret と UserAddress を使用してください
The party has already startedstartParty の実行後に出欠登録していますNOT_STARTED の場合にのみ出欠登録してください
Only the organizer can start主催者用のシークレットが間違っていますlocalStorage から再読み込みするか、再デプロイしてください
Contract balance wrong手数料の不一致、または一部の参加者しかチェックインしていませんentryFee * checkedInCount を確認してください
Invalid character 'm' at position 0Bech32 がバイト列として渡されています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 ID エラー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 を使用し、{ bytes } は UserAddress にのみ使用してください
ContractState.deserialize の引数型が一致しない16進文字列を直接渡していますContractState.deserialize(fromHex(action.state)) を使用してください。Uint8Array が必要です
ledger() が関数ではないコントラクト独自のエクスポートではなく、静的インポートを呼び出していますcontract/src/index.ts の getLedger() を使用してください(§6)
webpack で isomorphic-ws が見つからないNode のポリフィルがありませんnext.config.mjs にフォールバックとエイリアスを追加してください(§13)
pipeline is not a functionstream のポリフィルがありません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)。設定を1か所だけ変更し、文字列をあちこちで変更しないでください

18) エージェントのチェックリスト

  • 6つすべてのエクスポート済み回路とヘルパー回路を含む private-party.compact を作成する(§5)
  • コンパイルし、ZK アセットを public/zk/private-party/ に同期する
  • CompiledContract.withVacantWitnesses は compact-runtime ではなく @midnight-ntwrk/compact-js から使用する
  • 遅延読み込みの getCompiledContract()/getLedger() シングルトン(§6)
  • lib/midnight.ts は §9 から全体をコピーする。プロバイダーレイヤーを独自に変更しない
  • next.config.mjs と postcss.config.mjs は §13~14 から全体をコピーする
  • コンストラクター引数:[BigInt(partySize), BigInt(entryFeeStars), rawUint8Array]
  • 非シールドの Bech32 を wallet-sdk-address-format でデコードする
  • 主催者/参加者のシークレットを、コントラクトアドレスごとに localStorage に保存する
  • チェックインボタンの前に、プライバシーの境界について UI で説明する(§11 の文言を参照)
  • contract/ 内では npm install を実行しない
  • デモの前に、lib/midnight.ts の DEFAULT_NETWORK が対象ネットワークと一致していることを確認する

19) 拡張

保護された参加費

支払い中も参加者のプライバシーを守るため、checkIn を変更し、receiveUnshielded ではなくシールドトークンを使用します。チュートリアルのまとめと token-transfers/ スキルを参照してください。

マルチパーティテスト用 UI

コントラクトアドレスのクエリパラメーターを含む「招待リンクをコピー」を追加し、境界を越えた後に公開される checkedInParty アドレスを表示します。

ヘッドレス CI

公式の party.test.ts を example-hello-world/ の Docker Compose パターンを使ってテンプレートのモノレポに移植します。


20) 関連スキル

次のステップスキル
ウォレット接続のみreact-wallet-connector/
非シールドトークンのフローtoken-transfers/
決済ボールトのパターンexample-payment-dapp/
プライバシー監査security/
簡潔な言語リファレンスcompact/
ローカルの Vitest ハーネスexample-hello-world/

コメント