example-zk-loan-application
Build a zero-knowledge loan application on Midnight Network — privately evaluate credit score, income, and employment tenure inside a Compact circuit with Schnorr attestation, record only loan outcomes on-chain, and wire attestation API + CLI + optional Next.js frontend. Covers zkloan-credit-scorer.compact (witness-derived identity, tiered eligibility, PIN migration, admin governance), Jubjub Schnorr verification, attestation provider registration, and low-level deploy/call via 1AM wallet. Use for private credit scoring, attested witness data, lending dApps, or teaching disclose()/witness patterns with off-chain trusted signers. Triggers: ZK loan, credit scorer, attestation API, Schnorr signature Compact, requestLoan, private credit score, loan eligibility tiers, PIN identity, zkloan. Also use when extending leaderboard-dapp or locker-dapp wallet/provider patterns to multi-service monorepos.
How do I install this agent skill?
npx skills add https://github.com/kali-decoder/midnight-skills --skill example-zk-loan-applicationIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The skill provides a technical template and architectural guidance for building a zero-knowledge (ZK) loan application on the Midnight Network. It implements privacy-preserving logic, identity derivation from witnesses, and off-chain attestation. No malicious patterns or security risks were identified.
- Socketpass
No alerts
- Snykwarn
Risk: MEDIUM · 2 issues
What does this agent skill do?
Midnight Network ZK Loan Application
A privacy-preserving loan dApp where applicants prove eligibility inside a ZK circuit without revealing credit score, income, or tenure on-chain. A trusted attestation provider signs credit data off-chain; the contract verifies the Schnorr signature in-circuit and writes only the loan outcome (status + authorized amount) to the ledger.
What this skill produces:
contract/—schnorr.compact+zkloan-credit-scorer.compact, witnesses, compile scriptszkloan-credit-scorer-attestation-api/— REST server that Schnorr-signs credit profileszkloan-credit-scorer-cli/— deploy, register providers, request/respond to loans (headless wallet)app/loan/(optional) — Next.js + 1AM wallet UI (copy patterns fromtemplates/leaderboard-dapp/)lib/zkloan.ts— deploy,requestLoan,respondToLoan, admin calls, ledger decodelib/midnight.ts— wallet session + patched indexer provider (copy fromreferences/midnight-session.md)public/zk/zkloan/— ZK proving assets synced from contract build
Shared references (canonical provider + troubleshooting — do not duplicate in prompts):
references/midnight-session.md—createConnectedSession, indexer patch, deploy/call helpersreferences/gotchas.md— preprod deploy hangs, GraphQLoffset: null, ZK asset pathsreferences/versions.json— pinned@midnight-ntwrk/*versions
Primary references:
example-leaderboard-dapp//templates/leaderboard-dapp/— Next.js + 1AM, low-level deploy/call, indexer readsexample-locker-dapp/— witness private state,createUnprovenDeployTx+submitTxAsyncexample-counter/— headless CLI wallet, monorepo workspaces, vitest contract testscompact/—disclose(), witnesses,persistentHash,Map,Set,new type, pure circuitssecurity/— never trustownPublicKey()for caller identity; witness-derived keysindexer/— read public loan ledger without wallet
Key architecture notes:
- Credit score, income, tenure, and attestation signature stay private (witness only)
- Only loan status + authorized amount + derived user pubkey bytes appear on-chain
- Never use
ownPublicKey()for auth — it is prover-supplied and bypassable; derive identity fromgetUserSecret()witness + PIN viaderiveUserPublicKey/deriveAdminPublicKey - Attestation binds to
transientHash(userPubKeyBytes)insideevaluateApplicant - Eligibility tiers: ≥700 + ≥$2k + ≥24mo → $10k; ≥600 + ≥$1.5k → $7k; ≥580 → $3k; else rejected
requestLoanmay return Proposed when amount exceeds tier max — user callsrespondToLoanto accept/decline- PIN change migrates loans in batches of 5 via
changePin+onGoingPinMigrationledger map - Use
createUnprovenDeployTx+submitTxAsync— notdeployContract()(hangs on preprod) - Node.js ≥ 22 required for
@midnight-ntwrk/wallet-sdk-shielded(Iterator helpers onMap.values())
Workflow
When helping the user, follow this sequence:
- Monorepo — root
package.jsonworkspaces:contract,attestation-api,cli - Contract — compile Compact (
schnorr.compact+zkloan-credit-scorer.compact) + witnesses - Attestation API — generate/load provider Jubjub keypair; sign credit profiles; expose REST endpoint
- Deploy — CLI or browser: low-level deploy, persist
userSecretKey+ contract address in private state - Register provider — admin calls
registerProvider(providerId, providerPk)on-chain - Request loan — fetch attestation → populate private state →
requestLoan(amount, secretPin) - Respond — if status is
Proposed, callrespondToLoan(loanId, secretPin, accept) - Read state — indexer GraphQL → decode
loansnested map (public) - Frontend (optional) — connect 1AM, deploy/join, loan form, loan table, admin panel
1) Monorepo Structure
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/
Root package.json engines: "node": ">=22.0.0". Workspaces pin @midnight-ntwrk/compact-runtime: ^0.16.0, @midnight-ntwrk/midnight-js-*: 4.0.4, @midnight-ntwrk/ledger-v8: 8.0.3.
2) Compact Contract
Schnorr module (contract/src/schnorr.compact)
Verifies Jubjub Schnorr signatures inside the circuit. Temporary polyfill until jubjubSchnorrVerify ships in Compact Standard Library.
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 equality: compare jubjubPointX() / jubjubPointY() — struct == is reference equality and always fails for fresh points.
Main contract (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): [] { /* ... */ }
Privacy boundary:
| Data | Visibility |
|---|---|
| Credit score, income, tenure | Private (witness) |
| Attestation signature | Private (ZK input) |
| User secret + PIN | Private (witness / circuit input) |
Derived UserPublicKey (loan map key) | Public (ledger) |
| Loan status + authorized amount | Public (ledger) |
contractAdmin, providers, blacklist | Public (ledger) |
Compile:
cd contract
npm run compact # → src/managed/zkloan-credit-scorer/
npm run build
cd ..
For browser UI also run npm run sync:assets (copies keys/zkir → public/zk/zkloan/).
3) Witnesses
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],
};
Before requestLoan, TypeScript must:
- Call attestation API with credit profile + user's derived pubkey hash
- Store returned signature + provider ID in private state alongside credit fields
- Ensure
userSecretKeyis 32 bytes (persist inlocalStorageor CLI keystore)
4) Attestation API
Trusted off-chain signer. Flow:
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
Implementation checklist:
- Generate Jubjub provider keypair at startup (or load from env)
- Compute challenge via contract's exported
pureCircuits.schnorrChallenge(same hash as in-circuit) - Sign with provider secret key; return
{ announcement, response }+providerId - Admin must
registerProvider(providerId, providerPk)on-chain before any loan succeeds - Bind attestation to
transientHash(deriveUserPublicKey(secret, pin))— same hash the circuit uses
Example endpoint shape:
// POST /attest
// body: { creditScore, monthlyIncome, monthsAsCustomer, userPubKeyHash: string }
// response: { providerId, signature: { announcement: { x, y }, response } }
Run locally (default port e.g. 3001):
cd zkloan-credit-scorer-attestation-api
npm run dev
5) CLI (headless wallet)
Mirror example-counter/ CLI patterns:
| Command | Purpose |
|---|---|
deploy | createUnprovenDeployTx → stores deployer as admin via constructor |
register-provider <id> | Admin: on-chain provider pubkey from attestation API |
request-loan <amount> <pin> | Fetch attestation → set private state → requestLoan |
respond-loan <id> <pin> accept|decline | Accept or decline Proposed loan |
list-loans | Indexer read of public loans map for derived user key |
change-pin <old> <new> | Repeat until onGoingPinMigration cleared |
blacklist-user <userPk> | Admin governance |
Use @midnight-ntwrk/wallet-sdk-facade + level private state provider. Point at local proof server + preprod indexer from wallet config or .env.
6) TypeScript Integration (browser)
lib/zkloan.ts — same low-level pattern as templates/leaderboard-dapp/lib/leaderboard.ts:
| Function | Purpose |
|---|---|
getOrCreateUserSecret() | Persist 32-byte secret in localStorage |
deriveUserPublicKey(session, pin) | Call exported pure circuit off-chain |
fetchAttestation(apiUrl, profile, userPubKeyHash) | POST to attestation API |
deployZkLoan(session) | Deploy + seed private state with secret |
registerProvider(session, addr, id, pk) | Admin circuit call |
requestLoan(session, addr, amount, pin, profile) | Attest → update private state → requestLoan |
respondToLoan(session, addr, loanId, pin, accept) | Accept/decline proposed amount |
fetchLoanState(queryUrl, addr, userPkBytes) | Indexer poll + decode nested loans map |
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 asset path: /zk/zkloan (synced to public/zk/zkloan/).
7) Browser UI
Copy structure from templates/leaderboard-dapp/app/leaderboard/LeaderboardClient.tsx:
- Connect —
detectWallet()→wallet.connect('preprod')→createConnectedSession(api, ZK_PATH) - Deploy / Join — deploy new contract or paste existing address; persist in
localStorage - Admin panel (deployer only) — register attestation provider ID + pubkey from API
- Request loan — form: amount, secret PIN, credit fields → call attestation API →
requestLoan - My loans — decode indexer state for user's derived pubkey; show status badges (Approved / Proposed / Rejected)
- Respond — if
Proposed, buttons to accept max eligible amount or decline - Optional — PIN change wizard (call
changePinrepeatedly until migration complete)
Indexer reads work without wallet for public loan records — only writes need connection + attestation.
8) End-to-end local run
# 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
Wallet: 1AM extension on Preprod, proof server http://localhost:6300, tNIGHT + tDUST from faucet.
9) Prerequisites
| Component | Version |
|---|---|
| Node.js | ≥ 22 (required for shielded wallet SDK) |
| Compact compiler | 0.22–0.23 (pragma language_version) |
| Compact runtime | 0.16.0 |
| Ledger | 8.0.3 |
| midnight-js | 4.0.4 |
| proof server | 8.0.3 |
| Docker | For local proof server |
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) Security checklist
- Do not use
ownPublicKey()for blacklist, admin, or user identity checks - Attestation provider private key lives only on attestation server — never in browser private state
disclose()marks witness values safe to cross into ledger reads; credit fields never get disclosed- Blacklist stores derived
UserPublicKey, not wallet addresses - Re-deploy if domain-separator strings change —
persistentHashpreimages affect derived keys - Rate-limit attestation API; validate input ranges before signing
11) Production notes
- Deploy attestation API behind HTTPS; rotate provider keys via
rotateAdmin+ re-register provider - Frontend: Vercel/Netlify build step runs
npm run compact && npm run sync:assets - Store default contract address in env or
localStorage - For mainnet, update network ID, indexer URLs, proof server via wallet
getConfiguration() - Pin versions from
references/versions.json; cross-check support matrix
Quick Commands
# 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
Open http://localhost:3000 → Connect 1AM → Deploy → Register provider → Request loan → View outcome on-chain.
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/kali-decoder/midnight-skills/example-zk-loan-application">View example-zk-loan-application on skillZs</a>