example-zk-loan-application について
Compact と TypeScript を使って Midnight Network 上にプライバシー保護型ローン dApp を構築するための開発スキルです。ゼロ知識による適格性確認、オフチェーンの信用情報署名、CLI または任意の Web UI の実装を扱います。
主な機能
- 01ZK 回路でローン適格性を検証
- 02Schnorr による信用情報のオフチェーン署名
- 03デプロイとローン操作の CLI フロー
- 04任意の Next.js ローン画面
- 05公開ローン状態の照会
活用シーン
- 01収入や信用スコアを公開しないローンアプリの構築
- 02オフチェーン署名とオンチェーン検証の実装
- 03ローン申請、応答、状態照会フローの開発
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ kali-decoder/ midnight-skills --skill example-zk-loan-applicationインストール後、Claude Code で/example-zk-loan-applicationを入力して読み込まれたか確認します。
詳細
Midnight Network ZK Loan Application
クレジットスコア、収入、勤続年数をオンチェーンで公開することなく、申請者が ZK 回路内で適格性を証明する、プライバシーを保護するローン dApp です。信頼できる証明プロバイダーがクレジットデータにオフチェーンで署名し、コントラクトは回路内で Schnorr 署名を検証して、ローンの結果(ステータス+承認額)のみを台帳に書き込みます。
このスキルで生成されるもの:
contract/—schnorr.compactとzkloan-credit-scorer.compact、ウィットネス、コンパイルスクリプトzkloan-credit-scorer-attestation-api/— クレジットプロフィールに Schnorr 署名する REST サーバーzkloan-credit-scorer-cli/— ローンのデプロイ、プロバイダーの登録、リクエストと応答を行う CLI(ヘッドレスウォレット)app/loan/(オプション) — Next.js + 1AM ウォレット UI(templates/leaderboard-dapp/のパターンをコピー)lib/zkloan.ts— デプロイ、requestLoan、respondToLoan、管理者呼び出し、台帳のデコードlib/midnight.ts— ウォレットセッション+パッチ適用済みインデクサープロバイダー(references/midnight-session.mdからコピー)public/zk/zkloan/— コントラクトのビルドから同期された ZK 証明アセット
共通リファレンス(プロバイダーの正式な実装+トラブルシューティング。プロンプト内で重複させないでください):
references/midnight-session.md—createConnectedSession、インデクサーのパッチ、デプロイ/呼び出しヘルパーreferences/gotchas.md— preprod でのデプロイのハング、GraphQL のoffset: null、ZK アセットのパスreferences/versions.json— 固定された@midnight-ntwrk/*のバージョン
主なリファレンス:
example-leaderboard-dapp//templates/leaderboard-dapp/— Next.js + 1AM、低レベルのデプロイ/呼び出し、インデクサーからの読み取りexample-locker-dapp/— ウィットネスのプライベート状態、createUnprovenDeployTx+submitTxAsyncexample-counter/— ヘッドレス CLI ウォレット、モノレポのワークスペース、vitest によるコントラクトテストcompact/—disclose()、ウィットネス、persistentHash、Map、Set、new type、純粋回路security/— 呼び出し元の識別にownPublicKey()を決して信頼しないこと。ウィットネスからキーを導出しますindexer/— ウォレットを使わずに公開ローン台帳を読み取ります
アーキテクチャ上の重要な注意点:
- クレジットスコア、収入、勤続年数、証明署名はプライベートのままです(ウィットネスのみ)
- オンチェーンに記録されるのは、ローンのステータス+承認額+導出されたユーザー公開鍵のバイト列のみです
- 認証に決して
ownPublicKey()を使わないでください。これは証明者が指定できるため、回避可能です。getUserSecret()ウィットネス+PIN からderiveUserPublicKey/deriveAdminPublicKeyを使って本人確認情報を導出してください evaluateApplicant内でtransientHash(userPubKeyBytes)を使って証明を紐付けます- 適格性の段階:≥700 + ≥$2k + ≥24mo → $10k、≥600 + ≥$1.5k → $7k、≥580 → $3k、それ以外は却下
- 金額が段階ごとの上限を超える場合、
requestLoanはProposedを返すことがあります。ユーザーはrespondToLoanを呼び出して承認または辞退します - PIN の変更時は、
changePin+onGoingPinMigration台帳マップを使って、5 件ずつローンを移行します createUnprovenDeployTx+submitTxAsyncを使ってください。deployContract()は使わないでください(preprod でハングします)@midnight-ntwrk/wallet-sdk-shieldedの使用には Node.js ≥ 22 が必要です(Map.values()の Iterator ヘルパーを使用するため)
ワークフロー
ユーザーを支援する際は、次の手順に従ってください。
- モノレポ — ルートの
package.jsonワークスペース:contract、attestation-api、cli - コントラクト — Compact(
schnorr.compact+zkloan-credit-scorer.compact)とウィットネスをコンパイル - 証明 API — プロバイダーの Jubjub キーペアを生成/読み込み、クレジットプロフィールに署名し、REST エンドポイントを公開
- デプロイ — CLI またはブラウザーから低レベルのデプロイを行い、
userSecretKey+ コントラクトアドレスをプライベート状態に保存 - プロバイダーを登録 — 管理者がオンチェーンで
registerProvider(providerId, providerPk)を呼び出す - ローンをリクエスト — 証明を取得 → プライベート状態に入力 →
requestLoan(amount, secretPin) - 応答 — ステータスが
Proposedの場合、respondToLoan(loanId, secretPin, accept)を呼び出す - 状態を読み取る — インデクサーの GraphQL →
loansのネストしたマップをデコード(公開) - フロントエンド (オプション) — 1AM に接続し、デプロイ/参加、ローンフォーム、ローンテーブル、管理者パネルを用意
1) モノレポ構成
zkloan-credit-scorer/
├── package.json # workspaces + pinned midnight-js 4.0.4 / ledger 8.0.3
├── .nvmrc # 22
├── contract/
│ ├── package.json
│ └── src/
│ ├── schnorr.compact
│ ├── zkloan-credit-scorer.compact
│ ├── witnesses.ts
│ ├── index.ts
│ └── managed/zkloan-credit-scorer/ # compiler output (gitignored)
├── zkloan-credit-scorer-attestation-api/
│ └── src/ # Express/Fastify REST — sign credit profiles
├── zkloan-credit-scorer-cli/
│ └── src/ # deploy, register-provider, request-loan, admin
└── zkloan-dapp/ # optional Next.js frontend
├── lib/midnight.ts # copy from templates/leaderboard-dapp
├── lib/zkloan.ts
├── app/loan/LoanClient.tsx
├── contract/ # symlink or workspace import from ../contract
├── scripts/sync-zk-assets.mjs
└── public/zk/zkloan/
ルートの package.json のエンジン要件:"node": ">=22.0.0"。ワークスペースでは @midnight-ntwrk/compact-runtime: ^0.16.0、@midnight-ntwrk/midnight-js-*: 4.0.4、@midnight-ntwrk/ledger-v8: 8.0.3 にバージョンを固定しています。
2) Compact コントラクト
Schnorr モジュール(contract/src/schnorr.compact)
回路内で Jubjub Schnorr 署名を検証します。Compact Standard Library に jubjubSchnorrVerify が追加されるまでの一時的なポリフィルです。
module schnorr {
import CompactStandardLibrary;
export struct SchnorrSignature { announcement: JubjubPoint; response: Field; }
witness getSchnorrReduction(challengeHash: Field): [Field, Uint<248>];
export circuit schnorrVerify<#n>(msg: Vector<n, Field>, signature: SchnorrSignature, pk: JubjubPoint): [] { /* ... */ }
export pure circuit schnorrChallenge(...): Field { /* transientHash challenge */ }
}
JubjubPoint の等価性: jubjubPointX() / jubjubPointY() を比較してください。構造体の == は参照の等価性を比較するため、新しいポイントでは常に失敗します。
メインコントラクト(contract/src/zkloan-credit-scorer.compact)
pragma language_version >= 0.22 && <= 0.23;
import CompactStandardLibrary;
import "schnorr" prefix Schnorr_;
export enum LoanStatus { Approved, Rejected, Proposed, NotAccepted }
export struct LoanApplication { authorizedAmount: Uint<16>; status: LoanStatus; }
export new type UserSecretKey = Bytes<32>;
export new type UserPublicKey = Bytes<32>;
export new type AdminPublicKey = Bytes<32>;
export ledger blacklist: Set<UserPublicKey>;
export ledger loans: Map<Bytes<32>, Map<Uint<16>, LoanApplication>>;
export ledger onGoingPinMigration: Map<Bytes<32>, Uint<16>>;
export ledger contractAdmin: AdminPublicKey;
export ledger providers: Map<Uint<16>, JubjubPoint>;
witness getAttestedScoringWitness(): [Applicant, Schnorr_SchnorrSignature, Uint<16>];
witness getUserSecret(): UserSecretKey;
export pure circuit deriveUserPublicKey(sk: UserSecretKey, pin: Uint<16>): UserPublicKey { /* persistentHash domain "zkloan:user:pk:v1" */ }
export pure circuit deriveAdminPublicKey(sk: UserSecretKey): AdminPublicKey { /* "zkloan:admin:pk:v1" */ }
export circuit requestLoan(amountRequested: Uint<16>, secretPin: Uint<16>): [] { /* ... */ }
export circuit respondToLoan(loanId: Uint<16>, secretPin: Uint<16>, accept: Boolean): [] { /* ... */ }
export circuit changePin(oldPin: Uint<16>, newPin: Uint<16>): [] { /* batched migration, 5 per tx */ }
// Admin (all guard with deriveAdminPublicKey(getUserSecret()) == contractAdmin)
export circuit blacklistUser(account: UserPublicKey): [] { /* ... */ }
export circuit registerProvider(providerId: Uint<16>, providerPk: JubjubPoint): [] { /* ... */ }
export circuit rotateAdmin(newAdmin: AdminPublicKey): [] { /* ... */ }
プライバシー境界:
| データ | 可視性 |
|---|---|
| クレジットスコア、収入、勤続期間 | 非公開(witness) |
| アテステーション署名 | 非公開(ZK 入力) |
| ユーザーシークレット + PIN | 非公開(witness / 回路入力) |
派生した UserPublicKey(ローンマップのキー) | 公開(台帳) |
| ローンのステータス + 承認額 | 公開(台帳) |
contractAdmin、providers、blacklist | 公開(台帳) |
コンパイル:
cd contract
npm run compact # → src/managed/zkloan-credit-scorer/
npm run build
cd ..
ブラウザー UI では、npm run sync:assets も実行してください(keys/zkir を public/zk/zkloan/ にコピーします)。
3) Witness
contract/src/witnesses.ts:
export type ZKLoanCreditScorerPrivateState = {
creditScore: bigint;
monthlyIncome: bigint;
monthsAsCustomer: bigint;
attestationSignature: SchnorrSignature;
attestationProviderId: bigint;
userSecretKey: Uint8Array; // 32 bytes — authoritative caller identity
};
export const witnesses = {
getAttestedScoringWitness: (ctx) => [ctx.privateState, [profile, sig, providerId]],
getSchnorrReduction: (ctx, challengeHash) => [ctx.privateState, [q, r]], // challenge / 2^248
getUserSecret: (ctx) => [ctx.privateState, ctx.privateState.userSecretKey],
};
requestLoan の前に、TypeScript で以下を行う必要があります。
- 信用プロファイルと、ユーザーの派生 pubkey ハッシュを使ってアテステーション API を呼び出す
- 返された署名とプロバイダー ID を、信用情報とともにプライベート状態に保存する
userSecretKeyが 32 バイトであることを確認する(localStorageまたは CLI キーストアに保存)
4) アテステーション API
信頼できるオフチェーン署名者です。フロー:
sequenceDiagram
participant User
participant API as Attestation API
participant Wallet as 1AM / CLI
participant Chain as Midnight Network
User->>API: POST /attest { creditScore, income, tenure, userPubKeyHash }
API->>API: Schnorr-sign msg = [score, income, tenure, userPubKeyHash]
API-->>User: { signature, providerId }
User->>Wallet: requestLoan(amount, pin) + private state with attestation
Wallet->>Chain: ZK tx — evaluateApplicant verifies signature in-circuit
Chain-->>User: Loan Proposed / Approved / Rejected on ledger
実装チェックリスト:
- 起動時に Jubjub プロバイダーのキーペアを生成する(または環境変数から読み込む)
- コントラクトのエクスポートされた
pureCircuits.schnorrChallengeを使ってチャレンジを計算する(回路内と同じハッシュ) - プロバイダーのシークレットキーで署名し、
{ announcement, response }とproviderIdを返す - ローンが成功する前に、管理者が
registerProvider(providerId, providerPk)をオンチェーンで実行する必要がある - アテステーションを
transientHash(deriveUserPublicKey(secret, pin))にバインドする(回路が使うものと同じハッシュ)
エンドポイントの形状の例:
// POST /attest
// body: { creditScore, monthlyIncome, monthsAsCustomer, userPubKeyHash: string }
// response: { providerId, signature: { announcement: { x, y }, response } }
ローカルで実行する(デフォルトのポートは例として 3001):
cd zkloan-credit-scorer-attestation-api
npm run dev
5) CLI(ヘッドレスウォレット)
example-counter/ の CLI パターンを踏襲します。
| コマンド | 目的 |
|---|---|
deploy | createUnprovenDeployTx → コンストラクターを通じてデプロイヤーを管理者として保存 |
register-provider <id> | 管理者:アテステーション API からオンチェーンのプロバイダー公開鍵を登録 |
request-loan <amount> <pin> | アテステーションを取得 → プライベート状態を設定 → requestLoan |
respond-loan <id> <pin> accept|decline | Proposed ローンを承認または辞退 |
list-loans | 派生ユーザーキーの公開 loans マップをインデクサーから読み取る |
change-pin <old> <new> | onGoingPinMigration がクリアされるまで繰り返す |
blacklist-user <userPk> | 管理者によるガバナンス |
@midnight-ntwrk/wallet-sdk-facade と level プライベート状態プロバイダーを使用します。ウォレット設定または .env で、ローカルの証明サーバーと preprod インデクサーを指定してください。
6) TypeScript 統合(ブラウザー)
lib/zkloan.ts — templates/leaderboard-dapp/lib/leaderboard.ts と同じ低レベルパターンです。
| 関数 | 目的 |
|---|---|
getOrCreateUserSecret() | 32 バイトのシークレットを localStorage に保存 |
deriveUserPublicKey(session, pin) | エクスポートされた純粋回路をオフチェーンで呼び出す |
fetchAttestation(apiUrl, profile, userPubKeyHash) | アテステーション API に POST する |
deployZkLoan(session) | デプロイし、シークレットでプライベート状態を初期化する |
registerProvider(session, addr, id, pk) | 管理者による回路呼び出し |
requestLoan(session, addr, amount, pin, profile) | アテステーション → プライベート状態を更新 → requestLoan |
respondToLoan(session, addr, loanId, pin, accept) | 提案された金額を承認/辞退する |
fetchLoanState(queryUrl, addr, userPkBytes) | インデクサーをポーリングし、ネストされた loans マップをデコードする |
import { createUnprovenDeployTx, submitCallTxAsync, submitTxAsync } from '@midnight-ntwrk/midnight-js-contracts';
import { ContractState } from '@midnight-ntwrk/compact-runtime';
import { CompiledZKLoanContract, Contract, ledger } from '../contract/src/index';
export const ZK_PATH = '/zk/zkloan';
const PRIVATE_STATE_ID = 'zkloanPrivateState';
export async function requestLoan(session, contractAddress, amount, pin, attestation) {
await session.providers.privateStateProvider.set(PRIVATE_STATE_ID, {
...attestation,
userSecretKey: getOrCreateUserSecret(),
});
await submitCallTxAsync(session.providers, {
compiledContract: CompiledZKLoanContract,
contractAddress,
circuitId: 'requestLoan',
args: [BigInt(amount), BigInt(pin)],
privateStateId: PRIVATE_STATE_ID,
});
}
ZK アセットのパス:/zk/zkloan(public/zk/zkloan/ に同期されます)。
7) ブラウザー UI
templates/leaderboard-dapp/app/leaderboard/LeaderboardClient.tsx から構成をコピーします。
- 接続 —
detectWallet()→wallet.connect('preprod')→createConnectedSession(api, ZK_PATH) - デプロイ/参加 — 新しいコントラクトをデプロイするか、既存のアドレスを貼り付けます。
localStorageに保存します - 管理者パネル (デプロイしたユーザーのみ) — API からアテステーションプロバイダー ID と公開鍵を登録します
- ローンを申し込む — フォーム:金額、秘密の PIN、信用情報のフィールド → アテステーション API を呼び出す →
requestLoan - 自分のローン — ユーザーの派生公開鍵に対応するインデクサーの状態をデコードし、ステータスバッジ(Approved/Proposed/Rejected)を表示します
- 回答する —
Proposedの場合、適格な上限額を受け入れるボタン、または辞退するボタンを表示します - オプション — PIN 変更ウィザード(移行が完了するまで
changePinを繰り返し呼び出します)
公開ローン記録のインデクサー読み取りはウォレットなしで動作します。書き込みには接続とアテステーションのみが必要です。
8) エンドツーエンドのローカル実行
# Terminal 1 — proof server
docker run -d -p 6300:6300 midnightntwrk/proof-server:8.0.3 -- \
midnight-proof-server --network preprod
# Terminal 2 — attestation API
cd zkloan-credit-scorer-attestation-api && npm run dev
# Terminal 3 — CLI or Next.js UI
cd zkloan-credit-scorer-cli # or zkloan-dapp/
npm install
cd ../contract && npm run compact && npm run build && cd ..
npm run sync:assets # UI only
npm run deploy # CLI
npm run register-provider 1
npm run request-loan 5000 1234
ウォレット:Preprod 上の 1AM 拡張機能、プルーフサーバー http://localhost:6300、フォーセットから入手した tNIGHT と tDUST。
9) 前提条件
| コンポーネント | バージョン |
|---|---|
| Node.js | ≥ 22(シールドウォレット SDK に必須) |
| Compact コンパイラー | 0.22–0.23(pragma language_version) |
| Compact ランタイム | 0.16.0 |
| Ledger | 8.0.3 |
| midnight-js | 4.0.4 |
| プルーフサーバー | 8.0.3 |
| Docker | ローカルのプルーフサーバー用 |
node --version # v22+
compact compile --version
compact update
docker run -d -p 6300:6300 midnightntwrk/proof-server:8.0.3 -- \
midnight-proof-server --network preprod
10) セキュリティチェックリスト
- ブラックリスト、管理者、ユーザー ID の確認に
ownPublicKey()を使用しないでください - アテステーションプロバイダーの秘密鍵はアテステーションサーバーのみに保管し、ブラウザーのプライベート状態には決して含めないでください
disclose()は、証人値をレジャーの読み取りに渡しても安全であることを示します。信用情報のフィールドは決して公開されません- ブラックリストにはウォレットアドレスではなく、派生した
UserPublicKeyを保存します - ドメイン区切り文字列を変更した場合は再デプロイしてください。
persistentHashのプリイメージは派生キーに影響します - アテステーション API にレート制限を設け、署名する前に入力範囲を検証してください
11) 本番環境での注意事項
- アテステーション API を HTTPS の背後にデプロイします。
rotateAdminでプロバイダーキーをローテーションし、プロバイダーを再登録してください - フロントエンド:Vercel/Netlify のビルドステップで
npm run compact && npm run sync:assetsを実行します - デフォルトのコントラクトアドレスを環境変数または
localStorageに保存します - メインネットでは、ウォレットの
getConfiguration()を通じてネットワーク ID、インデクサー URL、プルーフサーバーを更新します references/versions.jsonのバージョンを固定し、サポートマトリックスと照合してください
クイックコマンド
# Contract only
cd contract && npm install && npm run compact && npm run build
# Full monorepo (from root)
npm install
npm run validate:registry # if added to workspace scripts
cd contract && npm run test:compile
# Optional browser UI (after adding zkloan-dapp from leaderboard template)
cd zkloan-dapp
npm install && npm run compact && npm run sync:assets && npm run dev
http://localhost:3000 を開く → 1AM に接続 → デプロイ → プロバイダーを登録 → ローンを申し込む → オンチェーンで結果を確認。