example-private-party-dapp について
Midnight Network上のプライベートパーティーDApp構築を支援する、CompactとNext.js向けの開発スキルです。実行可能なテンプレートを備え、RSVPのプライバシーがチェックインと支払い時にどう変わるかを説明します。
主な機能
- 01Next.jsのコントラクトとUIを生成
- 02非公開RSVPと公開チェックインを実装
- 03デプロイ、状態ポーリング、手数料請求に対応
- 04Compactのテストとトラブル対処を案内
活用シーン
- 01テンプレートからパーティーRSVP DAppを構築
- 02Compactコントラクトのプライバシー境界を学習
- 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を変更してください。これは唯一の信頼できる情報源であり、ほかの箇所でネットワーク文字列をハードコードしないでください。
ワークフロー
ユーザーを支援する際は、次の手順に従ってください。
- コントラクト —
private-party.compact(§5) を作成/スキャフォールドし、yarn compileでコンパイルします - プライバシー境界を理解する — 非公開の RSVP → 公開のチェックイン(アンシールド)→ 公開の支払い
- プロバイダー —
createConnectedSession(§9、lib/midnight.ts) - デプロイ — オーガナイザーはコンストラクターに
(partySize, entryFee, organizerSecret)を渡します - RSVP — 参加者はパーティー開始前に
rsvp(userAddress, secret)を呼び出します - 開始 — 準備ができたらオーガナイザーが
startParty(secret)を呼び出します(リストが満員になると自動的にREADYになります) - チェックイン — RSVP 済みのゲストは
checkIn(address, secret)を呼び出し、入場料を支払います(境界を越えます) - 終了 — 全員がチェックインしていない場合、オーガナイザーは
closeEntry(secret)を呼び出します。満員の場合は自動で終了します - 請求 — ドアを閉めた後、オーガナイザーは
claimFees(organizerAddress, secret)を呼び出します - UI — ロール選択、パーティー状態パネル、公開状態を取得するインデクサーのポーリング
1) プロジェクト構成
private-party-dapp/
├── package.json
├── next.config.mjs
├── postcss.config.mjs
├── lib/
│ ├── isomorphic-ws-fix.mjs
│ ├── midnight.ts # session, patched provider, hex helpers
│ ├── party.ts # deploy, circuits, decode state
│ ├── address.ts # Bech32 → UserAddress bytes
│ └── secret.ts # crypto.getRandomValues + localStorage
├── app/
│ ├── layout.tsx
│ ├── globals.css
│ └── party/
│ └── PartyClient.tsx # organizer + attendee UI
├── contract/
│ ├── package.json
│ └── src/
│ ├── private-party.compact
│ ├── index.ts # CompiledContract.withVacantWitnesses
│ └── managed/private-party/ # compiler output (gitignored)
├── scripts/
│ └── sync-zk-assets.mjs # → public/zk/private-party/
└── public/zk/private-party/ # keys + zkir (gitignored until sync)
任意のテストハーネス(公式リポジトリ — ブラウザーテンプレートには含まれません):
example-private-party/
├── contract/private-party.compact
├── src/test/party.test.ts # vitest: Alice organizer, Bob/Claire guests
├── compose.yml # node + indexer + proof-server
└── package.json # yarn test:local
2) 前提条件
node --version # 22+ for vitest harness; 20+ for Next.js frontend
docker --version # optional local devnet tests
curl --proto '=https' --tlsv1.2 -sSf \
https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh
source $HOME/.local/bin/env
ブラウザー:1AM ウォレット。lib/midnight.ts の DEFAULT_NETWORK に合わせてネットワークを設定します(デフォルトは preprod)。入場料用の tNIGHT をチャージしてください。
3) ルートの package.json
{
"name": "private-party-dapp",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --webpack",
"build": "npm run sync:assets && next build --webpack",
"compact": "npm run compact --prefix contract",
"sync:assets": "node scripts/sync-zk-assets.mjs",
"postinstall": "npm install --prefix contract"
},
"dependencies": {
"@midnight-ntwrk/compact-runtime": "0.16.0",
"@midnight-ntwrk/compact-js": "4.0.4",
"@midnight-ntwrk/ledger-v8": "8.0.3",
"@midnight-ntwrk/midnight-js-contracts": "4.0.4",
"@midnight-ntwrk/midnight-js-fetch-zk-config-provider": "4.0.4",
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.4",
"@midnight-ntwrk/midnight-js-network-id": "4.0.4",
"@midnight-ntwrk/midnight-js-types": "4.0.4",
"@midnight-ntwrk/wallet-sdk-address-format": "3.1.0",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0",
"stream-browserify": "^3.0.0",
"@tailwindcss/postcss": "^4.0.0",
"tailwindcss": "^4.0.0"
}
}
4) contract/package.json
{
"name": "@private-party/contract",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"compact": "compact compile src/private-party.compact src/managed/private-party"
},
"dependencies": {
"@midnight-ntwrk/compact-runtime": "0.16.0"
}
}
contract/ 内で npm install を実行しないでください — そこに @midnight-ntwrk/compact-runtime のコピーがもう 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件のテスト):
- コントラクトをデプロイ(Aliceが主催者)
- Bobが非公開でRSVP
- Alice(主催者)のRSVPが拒否される
- ClaireがRSVP
- Bobの
startPartyが拒否される - Aliceがパーティーを開始
- Bobがチェックインし、公開状態になる
- Bobの
closeEntryが拒否される - Aliceが入場を締め切る
- Aliceが
claimFeesを実行し、NIGHT残高がcheckedInCount * entryFeeだけ増加する - 手動でデプロイするテスト
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 started | startParty の実行後に出欠登録しています | NOT_STARTED の場合にのみ出欠登録してください |
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 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 function | stream のポリフィルがありません | 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/ |