example-payment-dapp について
Midnight Network上にプライバシー保護型の決済ボルトDAppを構築する開発スキルです。Compact、Next.js、1AMウォレットを使う開発者向けに、入出金、プロバイダー設定、インデクサーによる状態確認を扱います。
主な機能
- 01Compactの決済ボルトコントラクトを構成
- 02tNIGHTの入金と出金を実装
- 031AMウォレットのセッションとプロバイダーを設定
- 04インデクサーでコントラクト状態を確認
活用シーン
- 01テンプレートからプライバシー保護型決済DAppを開発
- 02所有者限定のコントラクト出金を実装
- 03デプロイ、証明生成、インデクサーの問題を調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ kali-decoder/ midnight-skills --skill example-payment-dappインストール後、Claude Code で/example-payment-dappを入力して読み込まれたか確認します。
詳細
Midnight Network Payment DApp
プライバシーを保護する決済用ボールトです。ユーザーは1AM ウォレットを通じて、ガス代無料で Compact スマートコントラクトを利用し、tNIGHT を入金・出金できます。
ワークフロー
ユーザーを支援する際は、次の順序で進めてください。
- 現在の作業段階を確認する:コントラクトの作成 → プロバイダーのセットアップ → デプロイ → 回路の呼び出し → インデクサー/状態のポーリング → UI
- コードを書く前に既知の注意点を確認する(
references/gotchas.mdを参照)。プロバイダーコードや回路コードを書く前に確認してください。 - プロバイダーの接続設定をコピーする:
references/midnight-session.mdを参照してください(すべてのブラウザー DApp で標準となるlib/midnight.ts)。 - 低レベルの SDK 関数を使用する(
createUnprovenDeployTx、submitTxAsync、submitCallTxAsync)。高レベルのラッパー(deployContract、createProofProvider)は preprod で動作しないため、使用しないでください。 - 公開データプロバイダーは必ず修正版でラップしてください。GraphQL の
offset: nullバグを回避できます。
アーキテクチャ
Browser (Next.js)
├── app/payment/PaymentClient.tsx ← client component (all UI + logic)
├── lib/midnight.ts ← wallet detection, session, providers (see references/midnight-session.md)
├── lib/payment.ts ← deploy, deposit, withdraw
└── public/zk/payment/ ← ZK proving assets (synced from contract build)
1AM Extension
├── detectWallet() → window.midnight['1am']
├── api.connect('preprod')
├── api.getConfiguration() → { networkId, indexerUri, ... }
├── api.getUnshieldedAddress()
├── api.getShieldedAddresses()
├── api.balanceUnsealedTransaction(hex)
└── api.submitTransaction(hex)
トランザクションの流れ:
- 1AM ウォレットを検出 → 接続 → すべてのプロバイダーを含むセッションを作成
- コントラクトをデプロイ(低レベル API)→ アドレスとプライベート状態を保存
- 入金:
receiveUnshielded回路を呼び出し → 状態の更新をインデクサーでポーリング - 出金:
sendUnshielded回路を呼び出し(所有者のみ)→ インデクサーをポーリング
Compact コントラクト
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger balance: Uint<128>;
export ledger totalDeposited: Uint<128>;
export ledger totalWithdrawn: Uint<128>;
export ledger owner: Bytes<32>;
witness ownerKey(): Bytes<32>;
constructor() {
balance = 0;
totalDeposited = 0;
totalWithdrawn = 0;
owner = disclose(deriveKey(ownerKey()));
}
export circuit deposit(amount: Uint<128>): [] {
receiveUnshielded(default<Bytes<32>>, disclose(amount));
totalDeposited = disclose((totalDeposited + amount) as Uint<128>);
balance = disclose((balance + amount) as Uint<128>);
}
export circuit withdraw(amount: Uint<128>, recipient: UserAddress): [] {
assert(deriveKey(ownerKey()) == owner, "Only owner can withdraw");
assert(balance >= amount, "Insufficient balance");
sendUnshielded(
default<Bytes<32>>,
disclose(amount),
right<ContractAddress, UserAddress>(disclose(recipient))
);
totalWithdrawn = disclose((totalWithdrawn + amount) as Uint<128>);
balance = disclose((balance - amount) as Uint<128>);
}
pure circuit deriveKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "payment:owner:v1"), sk]);
}
コンパイル:cd contract && npm run compact → 出力先はcontract/src/managed/payment/です。
プロバイダーのセットアップ(重要 — コードを書く前にお読みください)
セッションの型
export type ConnectedSession = {
api: any;
config: any;
providers: {
privateStateProvider: ReturnType<typeof createPrivateStateProvider>;
publicDataProvider: ReturnType<typeof createPatchedPublicDataProvider>;
zkConfigProvider: FetchZkConfigProvider<any>;
proofProvider: { proveTx: (unprovenTx: any) => Promise<any> };
walletProvider: WalletProvider;
midnightProvider: MidnightProvider;
};
unshieldedAddress: string;
coinPublicKeyBytes: Uint8Array; // ← pre-converted, never pass raw pk to circuits
};
createConnectedSession
export async function createConnectedSession(api: any, zkAssetBasePath: string): Promise<ConnectedSession> {
const [config, unshieldedAddress, shieldedAddress] = await Promise.all([
api.getConfiguration(),
api.getUnshieldedAddress(),
api.getShieldedAddresses(),
]);
setNetworkId(config.networkId);
const zkConfigProvider = new FetchZkConfigProvider(
new URL(zkAssetBasePath, window.location.origin).toString(),
window.fetch.bind(window),
);
const provingProvider = await api.getProvingProvider(zkConfigProvider);
const proofProvider = {
async proveTx(unprovenTx: any) {
const { CostModel } = await import('@midnight-ntwrk/ledger-v8');
return unprovenTx.prove(provingProvider, CostModel.initialCostModel());
},
};
const walletProvider: WalletProvider = {
getCoinPublicKey: () => shieldedAddress.shieldedCoinPublicKey,
getEncryptionPublicKey: () => shieldedAddress.shieldedEncryptionPublicKey,
balanceTx: async (tx: any) => {
const txHex = toHex(tx.serialize());
const balanced = await api.balanceUnsealedTransaction(txHex);
if (!balanced?.tx) throw new Error('balanceUnsealedTransaction returned invalid result');
const { Transaction } = await import('@midnight-ntwrk/ledger-v8');
return Transaction.deserialize('signature', 'proof', 'binding', fromHex(balanced.tx));
},
};
const midnightProvider: MidnightProvider = {
submitTx: async (tx: any) => {
const txHex = toHex(tx.serialize());
const result = await api.submitTransaction(txHex);
if (typeof result === 'string' && result) return result;
if (result?.transactionId) return result.transactionId;
if (result?.id) return result.id;
return txHex.slice(0, 64); // fallback pseudo-txId
},
};
return {
api, config,
providers: {
privateStateProvider: createPrivateStateProvider(),
publicDataProvider: createPatchedPublicDataProvider(config.indexerUri, config.indexerWsUri),
zkConfigProvider,
proofProvider,
walletProvider,
midnightProvider,
},
unshieldedAddress: unshieldedAddress.unshieldedAddress,
coinPublicKeyBytes: coinPublicKeyToBytes(shieldedAddress.shieldedCoinPublicKey),
};
}
coinPublicKeyToBytes(必須 — 形式はウォレットのバージョンによって異なります)
function coinPublicKeyToBytes(pk: unknown): Uint8Array {
if (pk instanceof Uint8Array) return pk.length === 32 ? pk : pk.slice(0, 32);
if (typeof pk === 'string') {
const hex = pk.startsWith('0x') ? pk.slice(2) : pk;
if (hex.length === 64 && /^[0-9a-fA-F]+$/.test(hex)) return fromHex(hex);
console.warn('coinPublicKey not hex, using fallback');
return new Uint8Array(32);
}
if (Array.isArray(pk)) return new Uint8Array(pk.length >= 32 ? pk.slice(0, 32) : [...pk, ...new Uint8Array(32 - pk.length)]);
if (pk && typeof pk === 'object' && 'bytes' in (pk as any)) return coinPublicKeyToBytes((pk as any).bytes);
return new Uint8Array(32);
}
修正版の公開データプロバイダー
function createPatchedPublicDataProvider(queryUrl: string, subscriptionUrl: string) {
const base = indexerPublicDataProvider(queryUrl, subscriptionUrl);
return {
...base,
async queryContractState(contractAddress: string, config?: any) {
if (config) return base.queryContractState(contractAddress, config);
const res = await fetch(queryUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
query: `query LATEST_CONTRACT_STATE($address: HexEncoded!) {
contractAction(address: $address) { state }
}`,
variables: { address: contractAddress },
}),
});
if (!res.ok) throw new Error(`Indexer HTTP error: ${res.status}`);
const payload = await res.json();
if (payload.errors?.length) throw new Error(payload.errors.map((e: any) => e.message).join('; '));
const action = payload.data?.contractAction ?? null;
return action ? ContractState.deserialize(fromHex(action.state)) : null;
},
};
}
プライベート状態プロバイダー(メモリー内)
function createPrivateStateProvider() {
let scope = '';
const stateStore = new Map<string, unknown>();
const signingKeyStore = new Map<string, unknown>();
const key = (id: string) => `${scope}:${id}`;
return {
setContractAddress(address: string) { scope = address; },
async set(id: string, state: unknown) { stateStore.set(key(id), state); },
async get(id: string) { return stateStore.get(key(id)) ?? null; },
async remove(id: string) { stateStore.delete(key(id)); },
async clear() { stateStore.clear(); },
async setSigningKey(addr: string, k: unknown) { signingKeyStore.set(addr, k); },
async getSigningKey(addr: string) { return signingKeyStore.get(addr) ?? null; },
async removeSigningKey(addr: string) { signingKeyStore.delete(addr); },
async clearSigningKeys() { signingKeyStore.clear(); },
async exportPrivateStates(): Promise<never> { throw new Error('Not implemented.'); },
async importPrivateStates(): Promise<never> { throw new Error('Not implemented.'); },
async exportSigningKeys(): Promise<never> { throw new Error('Not implemented.'); },
async importSigningKeys(): Promise<never> { throw new Error('Not implemented.'); },
};
}
デプロイと回路の呼び出し
デプロイ(低レベル API を使用 — 高レベル API は preprod で停止します)
export async function deployPayment(session: ConnectedSession, ownerKey: Uint8Array): Promise<string> {
const compiledContract = makeCompiledContract();
const initialPrivateState = { ownerSecretKey: ownerKey };
const deployTxData = await (createUnprovenDeployTx as any)(
{ zkConfigProvider: session.providers.zkConfigProvider, walletProvider: session.providers.walletProvider },
{ compiledContract, args: [], 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, initialPrivateState);
await session.providers.privateStateProvider.setSigningKey(contractAddress, deployTxData.private.signingKey);
return contractAddress;
}
入金/出金
export async function depositPayment(session: ConnectedSession, contractAddress: string, amount: bigint) {
await (submitCallTxAsync as any)(session.providers, {
compiledContract: makeCompiledContract(),
contractAddress,
circuitId: 'deposit',
args: [amount],
privateStateId: PRIVATE_STATE_ID,
});
}
export async function withdrawPayment(session: ConnectedSession, contractAddress: string, amount: bigint, recipientBytes: Uint8Array) {
await (submitCallTxAsync as any)(session.providers, {
compiledContract: makeCompiledContract(),
contractAddress,
circuitId: 'withdraw',
args: [amount, { bytes: recipientBytes }],
privateStateId: PRIVATE_STATE_ID,
});
}
コンパイル済みコントラクトビルダー
function makeCompiledContract() {
const witnesses = {
ownerKey: (context: any) => [context.privateState, context.privateState.ownerSecretKey],
};
return CompiledContract.make('payment', Payment.Contract).pipe(
CompiledContract.withWitnesses(witnesses),
CompiledContract.withCompiledFileAssets(ZK_ASSET_PATH),
) as any;
}
インデクサーと状態のポーリング
export async function pollForState(
queryUrl: string,
contractAddress: string,
onProgress?: (attempt: number) => void,
maxAttempts = 120,
intervalMs = 2000,
): Promise<string> {
for (let i = 0; i < maxAttempts; i++) {
onProgress?.(i + 1);
const state = await fetchContractState(queryUrl, contractAddress);
if (state) return state;
await new Promise(r => setTimeout(r, intervalMs));
}
throw new Error(`State not found after ${maxAttempts * intervalMs / 1000}s`);
}
export function decodePaymentState(stateHex: string) {
const contractState = ContractState.deserialize(fromHex(stateHex));
const ledger = Payment.ledger(contractState.data); // ← pass .data, not contractState itself
return {
balance: ledger.balance as unknown as bigint,
totalDeposited: ledger.totalDeposited as unknown as bigint,
totalWithdrawn: ledger.totalWithdrawn as unknown as bigint,
};
}
Next.js の設定
WebSocket shim(lib/isomorphic-ws-fix.mjs)
export default globalThis.WebSocket;
export const WebSocket = globalThis.WebSocket;
next.config.mjs
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
export default {
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve.fallback = { ...config.resolve.fallback, fs: false, net: false, tls: false, child_process: false };
config.resolve.alias = { ...config.resolve.alias, "isomorphic-ws": require.resolve("./lib/isomorphic-ws-fix.mjs") };
}
config.experiments = { ...config.experiments, asyncWebAssembly: true, topLevelAwait: true };
return config;
},
};
package.json のスクリプト — --webpackを必ず使用
{ "dev": "next dev --webpack", "build": "next build --webpack" }
トークンの単位
| 単位 | 換算 |
|---|---|
| 1 NIGHT | 1,000,000 Stars |
| 1 Star | 1 基本単位(すべてのトランザクションで使用) |
現実的な残高では Stars が JS のnumberの上限を超えるため、必ずBigIntを使用してください。
既知の問題と修正方法
| 症状 | 原因 | 修正方法 |
|---|---|---|
Invalid character 'm' at position 0 | encodeUserAddress()に生の coin pk が渡された | encodeUserAddressは使用しないでください。coinPublicKeyToBytes()を使用し、回路には{ bytes: ... }を渡してください |
| デプロイが何も表示せず30~120秒間停止する | deployContract()がwatchForTxDataを呼び出す | createUnprovenDeployTxとsubmitTxAsyncを使用してください |
| 証明に失敗する | createProofProvider()に CostModel がない | unprovenTx.prove(provingProvider, CostModel.initialCostModel())を直接呼び出してください |
GraphQL のoffset: nullエラー | デフォルトのqueryContractStateが null の offset を送信する | 修正版の公開データプロバイダーを使用してください(カスタム GraphQL クエリで offset を省略します) |
残高の取得時にCannot read properties of nullが発生する | balanceUnsealedTransaction()が null を返した | ガードを追加してください:if (!balanced?.tx) throw new Error(...) |
| 送信後に Tx ID が見つからない | submitTransaction()が文字列ではなくオブジェクトを返す | 次の順で正規化してください:文字列 → .transactionId → .id → 16進数プレフィックスへのフォールバック |
ledger()で型が一致せず失敗する | .dataではなくcontractStateを渡している | Payment.ledger()には必ずcontractState.data(ChargedState)を渡してください |
| ZK アセットが404になり、SDK で不可解なエラーが発生する | アセットが public/ に同期されていない | 開発前にnpm run sync:assetsを実行し、ブラウザーで URL に直接アクセスして確認してください |
| WASM/トップレベル await エラー | webpack の設定がない、または Turbopack を使用している | asyncWebAssembly: trueとtopLevelAwait: trueを追加し、--webpackフラグを使用してください |
| 誤ったウォレットが検出される | Lace と1AM の両方が存在する | まずwindow.midnight['1am']を確認し、見つからない場合はwindow.midnight?.mnLaceにフォールバックしてください |
ZK アセットのホスティング
npm run sync:assets を実行して、contract/src/managed/payment/{keys,zkir}/ → public/zk/payment/ にコピーします。
プロバイダー/SDK のエラーをデバッグする前に、アセットの URL を直接開いてください:
http://localhost:3000/zk/payment/keys/deposit.prover — ここで 404 が返されると、SDK のエラーが分かりにくくなります。
FetchZkConfigProvider は実行時に /zk/payment/ から取得します。Next.js はデフォルトで public/ を配信するため、追加の設定は不要です。