For the complete documentation index, see llms.txt
ZK Loan smart contract
This tutorial builds a zero-knowledge (ZK) loan application from scratch on Midnight Network. The app privately evaluates a user's credit data (credit score, income, and employment tenure) using ZK proofs and records only the loan outcome on-chain. The sensitive financial data never leaves your machine.
Traditional lending requires sharing sensitive financial data with lenders, brokers, and underwriters. That data gets stored, shared, and inevitably leaked. In 2024 alone, financial data breaches exposed hundreds of millions of records. ZK proofs flip this model. Instead of showing your data, you prove a statement about it: "My credit score is above 700, and my income exceeds $2,000/month," and the verifier learns nothing else. Midnight Network makes this practical — it is a blockchain purpose-built for data protection, where smart contracts can process private inputs and commit only the results on-chain.
The tutorial is split into three parts:
- Smart contract: Written in Compact (Midnight's ZK language). It defines the loan logic, eligibility tiers, and on-chain state.
- Attestation API: A server that signs credit data with Schnorr signatures so the smart contract can verify the data came from a trusted source.
- CLI: A command-line tool to deploy the smart contract, register the attestation provider, request loans, and interact with the system — all pointing at Midnight's local network.
Project setup
This section covers the tools and directory structure needed before writing any code.
Install prerequisites
Make sure you have Node.js v22 or newer installed:
node --version
# Should print v22.x.x or higher
This DApp uses @midnight-ntwrk/wallet-sdk-shielded, which calls Map.prototype.values().map(...) on the shielded wallet state. That requires the Iterator helpers added in Node 22. On Node 20 the wallet appears to start, then crashes on the first sync update with state.pendingOutputs.values.map is not a function. If you use nvm, add a .nvmrc file containing 22 (or your preferred 22+ release) to the repo root so nvm use picks it up automatically.
Install the Midnight Compact compiler. Follow the instructions in the installation guide to install the Compact toolchain on your system.
Verify it is available:
compact compile --version
Run compact update to install the latest Compact toolchain. The workspace package.json declares @midnight-ntwrk/compact-runtime: ^0.16.0, which matches what the latest compact compile emits.
compact-js@2.5.0 declares an exact-pin on compact-runtime@0.15.0, but that pin only applies to compact-js's internal resolution — npm nests the 0.15 build inside node_modules/@midnight-ntwrk/compact-js/node_modules/ and serves 0.16 to your application code at the top level. There is no workspace-level version conflict, and you do not need to do anything about the nested copy.
Also, Docker is required to run the Midnight proof server, which generates the ZK proofs for your transactions locally. Install it from docker.com/get-docker if you have not already, and make sure the Docker daemon is running.
Create the project structure
Create the root project directory and initialize the monorepo configuration:
mkdir zkloan-credit-scorer
cd zkloan-credit-scorer
Initialize the root package.json with the following code snippet by pasting directly into your terminal. This is a monorepo with three workspaces:
cat > package.json << 'EOF'
{
"name": "zkloan-credit-scorer",
"version": "3.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.0.0"
},
"workspaces": [
"contract",
"zkloan-credit-scorer-cli",
"zkloan-credit-scorer-attestation-api"
],
"devDependencies": {
"@types/node": "^25.0.1",
"@types/ws": "^8.18.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"vitest": "^4.0.15"
},
"dependencies": {
"@midnight-ntwrk/compact-js": "^2.5.0",
"@midnight-ntwrk/compact-runtime": "^0.16.0",
"@midnight-ntwrk/ledger-v8": "8.0.3",
"@midnight-ntwrk/midnight-js-contracts": "4.0.4",
"@midnight-ntwrk/midnight-js-http-client-proof-provider": "4.0.4",
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.4",
"@midnight-ntwrk/midnight-js-level-private-state-provider": "4.0.4",
"@midnight-ntwrk/midnight-js-network-id": "4.0.4",
"@midnight-ntwrk/midnight-js-node-zk-config-provider": "4.0.4",
"@midnight-ntwrk/midnight-js-types": "4.0.4",
"@midnight-ntwrk/midnight-js-utils": "4.0.4",
"@midnight-ntwrk/wallet-sdk-address-format": "3.1.0",
"@midnight-ntwrk/wallet-sdk-dust-wallet": "3.0.0",
"@midnight-ntwrk/wallet-sdk-facade": "3.0.0",
"@midnight-ntwrk/wallet-sdk-hd": "3.0.0",
"@midnight-ntwrk/wallet-sdk-shielded": "2.1.0",
"@midnight-ntwrk/wallet-sdk-unshielded-wallet": "2.1.0",
"@scure/bip39": "^2.0.1",
"dotenv": "^17.2.3",
"pino": "^10.1.0",
"pino-pretty": "^13.1.3",
"rxjs": "^7.8.1",
"ws": "^8.18.3"
},
"overrides": {
"smoldot": "npm:@aspect-build/empty@0.0.0",
"@midnight-ntwrk/ledger-v8": "8.0.3",
"@midnight-ntwrk/midnight-js-network-id": "4.0.4"
},
"resolutions": {
"@midnight-ntwrk/ledger-v8": "8.0.3",
"@midnight-ntwrk/midnight-js-network-id": "4.0.4"
}
}
EOF
Next, create the .gitignore with the following command:
cat > .gitignore << 'EOF'
node_modules/
**/dist/
.vite/
*.tsbuildinfo
logs
*.log
midnight-level-db
coverage
**/reports
.npm
.eslintcache
.env
**/.DS_Store
.vscode/
managed/
EOF
Create the three workspace directories:
mkdir -p contract/src
mkdir -p zkloan-credit-scorer-cli/src
mkdir -p zkloan-credit-scorer-attestation-api/src
Write the Schnorr signature module
Before writing the main smart contract, you need a module for verifying Schnorr signatures. This is how the smart contract verifies that credit data was signed by a trusted attestation provider.
This signature is important because of a fundamental problem: the witness's data cannot be trusted on its own. The witness runs on your machine, which means you could feed in any credit score you want. A user claiming a 750 credit score with $5,000 monthly income? Nothing stops them from lying.
That is why the attestation solution exists. A trusted provider (a bank, credit bureau, or scoring service) signs your real data with a cryptographic signature. The smart contract then verifies that signature within the ZK circuit. If the data was tampered with, the signature check fails, and the transaction reverts.
For this, the smart contract uses Schnorr signatures on the Jubjub elliptic curve, Midnight's native internal curve.
The Schnorr verification module below is a temporary polyfill. The Midnight team is building jubjubSchnorrVerify directly into the Compact Standard Library. Once that ships, this entire module gets replaced by a single built-in function call. For now, implementing it manually is a useful exercise in understanding how signature verification works inside a ZK circuit.
Now, create a file called schnorr.compact inside contract/src/ and add the following code snippet:
module schnorr {
import CompactStandardLibrary;
export struct SchnorrSignature {
announcement: JubjubPoint;
response: Field;
}
struct SchnorrHashInput<#n> {
ann_x: Field;
ann_y: Field;
pk_x: Field;
pk_y: Field;
msg: Vector<n, Field>;
}
witness getSchnorrReduction(challengeHash: Field): [Field, Uint<248>];
export circuit schnorrVerify<#n>(msg: Vector<n, Field>, signature: SchnorrSignature, pk: JubjubPoint): [] {
const {announcement, response} = signature;
const cFull: Field = transientHash<SchnorrHashInput<n>>(SchnorrHashInput<n>{
ann_x: jubjubPointX(announcement),
ann_y: jubjubPointY(announcement),
pk_x: jubjubPointX(pk),
pk_y: jubjubPointY(pk),
msg: msg
});
const TWO_248: Field = 452312848583266388373324160190187140051835877600158453279131187530910662656 as Field;
const [q, cTruncated] = getSchnorrReduction(cFull);
assert(disclose(q) * TWO_248 + (disclose(cTruncated) as Field) == cFull, "Invalid challenge reduction");
const c: Field = disclose(cTruncated) as Field;
const lhs: JubjubPoint = ecMulGenerator(response);
const rhs: JubjubPoint = ecAdd(announcement, ecMul(pk, c));
assert(jubjubPointX(lhs) == jubjubPointX(rhs) && jubjubPointY(lhs) == jubjubPointY(rhs),
"Invalid attestation signature");
}
export pure circuit schnorrChallenge(
ann_x: Field, ann_y: Field,
pk_x: Field, pk_y: Field,
msg: Vector<4, Field>
): Field {
const cFull: Field = transientHash<SchnorrHashInput<4>>(SchnorrHashInput<4>{
ann_x: ann_x, ann_y: ann_y,
pk_x: pk_x, pk_y: pk_y,
msg: msg
});
return cFull;
}
}
In the Schnorr code above:
SchnorrSignature is a struct with two fields:
-
announcement: A point on the Jubjub elliptic curve (the "R" in Schnorr signing) -
response: A scalar field element (the "s" in Schnorr signing)
schnorrVerify is the verification circuit. It:
-
Hashes the announcement coordinates, public key coordinates, and message into a challenge (
cFull) -
Truncates the challenge to 248 bits (because the Jubjub curve order is ~252 bits, and
transientHashoutputs values in BLS12-381's scalar field, which is ~255 bits) -
Verifies the Schnorr equation:
G * response == announcement + publicKey * challenge
JubjubPoint equalitySince Compact language version 0.22, two JubjubPoint values can no longer be compared with ==. The compiler emits JS reference equality for struct equality, which is always false for freshly constructed points and would make the assertion silently impossible to satisfy. Compare the x and y coordinates explicitly with jubjubPointX() / jubjubPointY(), as shown above.
The truncation uses a witness (getSchnorrReduction). The TypeScript code provides the quotient and remainder of dividing by 2^248, and the circuit verifies q * 2^248 + r == cFull. This is a common pattern in ZK systems: let the prover compute something expensive off-chain, then verify the result cheaply on-chain.
schnorrChallenge is a pure circuit (no side effects, no ledger access). It computes the same challenge hash that schnorrVerify uses. It is exported so the attestation API can compute the same hash off-chain when signing.
Now that this is completed, you can write the actual loan smart contract in the following steps.
Write the loan smart contract
Create zkloan-credit-scorer.compact inside the contract/src/ folder.
The header, types, ledger state, and constructor
This first block declares the language version, imports, data types, ledger state, the constructor (which derives the initial admin public key from a witness secret), and the two pure circuits that turn that secret into per-user and admin identities:
pragma language_version >= 0.22 && <= 0.23;
import CompactStandardLibrary;
import "schnorr" prefix Schnorr_;
export { Schnorr_SchnorrSignature };
export enum LoanStatus {
Approved,
Rejected,
Proposed,
NotAccepted,
}
export struct LoanApplication {
authorizedAmount: Uint<16>;
status: LoanStatus;
}
struct Applicant {
creditScore: Uint<16>;
monthlyIncome: Uint<16>;
monthsAsCustomer: Uint<16>;
}
// Every browser/CLI instance holds a single 32-byte secret in private state.
// All identity in this contract — both the admin role and per-user loan
// identity — derives from that one secret via domain-separated hashes.
// `ownPublicKey()` is never used: it returns a prover-claimed value with no
// cryptographic binding to the transaction signer, so any assertion that
// depends on it is bypassable.
export struct UserSecretKey {
bytes: Bytes<32>;
}
export struct UserPublicKey {
bytes: Bytes<32>;
}
export struct AdminPublicKey {
bytes: Bytes<32>;
}
constructor() {
contractAdmin = disclose(deriveAdminPublicKey(getUserSecret()));
}
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;
// Per-user identity, PIN-rotatable. Changing the PIN yields a new derived
// public key, breaking linkability to the old identity.
export pure circuit deriveUserPublicKey(sk: UserSecretKey, pin: Uint<16>): UserPublicKey {
const pinBytes = persistentHash<Uint<16>>(pin);
return UserPublicKey {
bytes: persistentHash<Vector<3, Bytes<32>>>([
pad(32, "zkloan:user:pk:v1"),
pinBytes,
sk.bytes
])
};
}
// Admin identity. No PIN binding — the admin role is stable across the
// admin's PIN rotations. The deployer's `deriveAdminPublicKey(secret)` is
// frozen into `contractAdmin` at construction.
export pure circuit deriveAdminPublicKey(sk: UserSecretKey): AdminPublicKey {
return AdminPublicKey {
bytes: persistentHash<Vector<2, Bytes<32>>>([
pad(32, "zkloan:admin:pk:v1"),
sk.bytes
])
};
}
Every Compact smart contract starts with a pragma (the language version) and imports. The standard library and the Schnorr module are imported with a prefix so their names do not collide.
Define the data types next. Note two key things:
LoanApplicationandLoanStatusare exported. Theexportkeyword does not make them public — it generates TypeScript bindings in the compiled artifacts so the DApp can read and construct these types. They are visible on-chain because they are the value and field types stored in theloansledger map; all ledger state is public, so anyone can read a loan's status and authorized amount.Applicantis not exported, so no TypeScript binding is generated for it. It stays private because it is only ever used as witness and circuit-internal data and is never written to ledger state — not because of the missingexport. The credit score, income, and tenure never appear on-chain.
UserSecretKey, UserPublicKey, and AdminPublicKey each wrap a single Bytes<32>. The wrapper structs are deliberate: they are all 32-byte values, but giving each a distinct named type makes them nominally distinct in Compact, so the compiler rejects passing a UserPublicKey where an AdminPublicKey is expected (and vice versa). A bare Bytes<32> everywhere would compile too, but it would let a user key and an admin key be used interchangeably — exactly the kind of confusion this contract's security depends on avoiding.
The ledger declarations define what lives on the blockchain:
loansis a nested map: the outer key is the user's derived public key bytes (Bytes<32>), the inner key is a loan ID (Uint<16>), and the value is theLoanApplication. This lets each user have multiple loans.providersmaps a provider ID to a Jubjub curve point (the provider's public key). The smart contract verifies attestation signatures against these registered keys.contractAdminstores the derived admin public key of the deployer:persistentHash("zkloan:admin:pk:v1" || userSecret). The deployer holds the 32-byte secret in private state; the ledger only ever sees the hash. Each admin circuit forces the caller, inside the ZK proof, to demonstrate knowledge of the preimage of this stored value.blackliststores derivedUserPublicKeyvalues, not wallet addresses. A user'sUserPublicKeyat a given PIN ispersistentHash("zkloan:user:pk:v1" || hash(pin) || userSecret). A malicious caller cannot bypass the blacklist check by claiming a different wallet pubkey — their identity in this contract is whatever their witness secret hashes to.onGoingPinMigrationtracks progress when a user changes their PIN (more on this in the identity section).deriveUserPublicKey(sk, pin)is the per-user identity derivation;deriveAdminPublicKey(sk)is the admin derivation. Both consume the same 32-byte witness secret. Different domain-separator strings keep the two derivations uncorrelated.
Two witnesses. getAttestedScoringWitness returns the applicant's credit profile and the Schnorr-signed attestation. getUserSecret returns the 32-byte secret that drives all identity in the contract — the admin role and per-user PIN-bound identity. The TypeScript implementation is covered in the next section.
Do not close the file yet — the remaining blocks get appended to it.
Core loan circuits
Now add the heart of the smart contract: the circuits that handle loan requests. Use the following code snippet:
export circuit requestLoan(amountRequested: Uint<16>, secretPin: Uint<16>): [] {
assert(amountRequested > 0, "Loan amount must be greater than zero");
const requesterPubKey = deriveUserPublicKey(getUserSecret(), secretPin);
const disclosedRequesterPubKey = disclose(requesterPubKey);
assert(!blacklist.member(disclosedRequesterPubKey), "Requester is blacklisted");
assert(!onGoingPinMigration.member(disclosedRequesterPubKey.bytes),
"PIN migration is in progress for this user");
const userPubKeyHash = transientHash<Bytes<32>>(disclosedRequesterPubKey.bytes);
const [topTierAmount, status] = evaluateApplicant(userPubKeyHash);
const disclosedTopTierAmount = disclose(topTierAmount);
const disclosedStatus = disclose(status);
createLoan(disclosedRequesterPubKey.bytes, amountRequested, disclosedTopTierAmount, disclosedStatus);
}
export circuit respondToLoan(loanId: Uint<16>, secretPin: Uint<16>, accept: Boolean): [] {
const requesterPubKey = deriveUserPublicKey(getUserSecret(), secretPin);
const disclosedRequesterPubKey = disclose(requesterPubKey);
const disclosedPubKey = disclosedRequesterPubKey.bytes;
const disclosedLoanId = disclose(loanId);
assert(!blacklist.member(disclosedRequesterPubKey), "User is blacklisted");
assert(loans.member(disclosedPubKey), "No loans found for this user");
assert(loans.lookup(disclosedPubKey).member(disclosedLoanId), "Loan not found");
const existingLoan = loans.lookup(disclosedPubKey).lookup(disclosedLoanId);
assert(existingLoan.status == LoanStatus.Proposed, "Loan is not in Proposed status");
const updatedLoan = accept
? LoanApplication { authorizedAmount: existingLoan.authorizedAmount, status: LoanStatus.Approved }
: LoanApplication { authorizedAmount: 0, status: LoanStatus.NotAccepted };
loans.lookup(disclosedPubKey).insert(disclosedLoanId, disclose(updatedLoan));
}
circuit evaluateApplicant(userPubKeyHash: Field): [Uint<16>, LoanStatus] {
const [profile, signature, providerId] = getAttestedScoringWitness();
assert(providers.member(disclose(providerId)), "Attestation provider not registered");
const providerPk = providers.lookup(disclose(providerId));
const msg: Vector<4, Field> = [
profile.creditScore as Field,
profile.monthlyIncome as Field,
profile.monthsAsCustomer as Field,
userPubKeyHash
];
Schnorr_schnorrVerify<4>(msg, signature, providerPk);
if (profile.creditScore >= 700 && profile.monthlyIncome >= 2000 && profile.monthsAsCustomer >= 24) {
return [10000, LoanStatus.Approved];
}
else if (profile.creditScore >= 600 && profile.monthlyIncome >= 1500) {
return [7000, LoanStatus.Approved];
}
else if (profile.creditScore >= 580) {
return [3000, LoanStatus.Approved];
}
else {
return [0, LoanStatus.Rejected];
}
}
circuit createLoan(requester: Bytes<32>, amountRequested: Uint<16>, topTierAmount: Uint<16>, status: LoanStatus): [] {
const authorizedAmount = amountRequested > topTierAmount ? topTierAmount : amountRequested;
const finalStatus = status == LoanStatus.Rejected
? LoanStatus.Rejected
: (amountRequested > topTierAmount ? LoanStatus.Proposed : LoanStatus.Approved);
const loan = LoanApplication {
authorizedAmount: authorizedAmount,
status: finalStatus,
};
if(!loans.member(requester)) {
loans.insert(requester, default<Map<Uint<16>, LoanApplication>>);
}
const totalLoans = loans.lookup(requester).size();
assert(totalLoans < 65535, "Maximum number of loans reached");
const loanNumber = (totalLoans + 1) as Uint<16>;
loans.lookup(requester).insert(loanNumber, disclose(loan));
}
requestLoan is the main entry point that users call. Here is the flow:
- Derive the caller's per-user identity with
deriveUserPublicKey(getUserSecret(), secretPin). The witness secret is the only authoritative caller identity in this contract. disclose()the derived pubkey so the compiler will let the value flow into ledger reads (blacklist.member,onGoingPinMigration.member). The value is witness-derived, so explicit disclosure is required.- Check the user is not on the blacklist or mid-PIN-change.
- Call
evaluateApplicant()— this is where the private credit scoring happens. ThetransientHashof the disclosed pubkey becomes theuserPubKeyHashthat the attestation message is signed against, binding the off-chain attestation to this specific in-circuit identity. disclose()only the results (amount and status), and write the loan record to the ledger.
evaluateApplicant runs entirely in the ZK circuit. It reads the user's credit data from the witness, verifies the attestation signature, and returns the eligibility tier. disclose() does not itself make anything public — it is a compile-time annotation that marks a witness-derived value as safe to leave the private domain. A value only becomes public when it crosses a public boundary: written to a ledger field, or returned from an exported circuit. The credit score, income, and tenure are never disclosed and never cross such a boundary, so they stay private; only the eligibility outcome (amount and status) is disclosed and then written to the ledger, which is what actually publishes it.
Also, evaluateApplicant is an internal circuit (not exported, cannot be called from outside). It:
- Gets the user's credit profile, Schnorr signature, and provider ID from the witness
- Verifies the provider is registered on-chain
- Verifies the Schnorr signature — this proves the data came from a trusted provider and was not fabricated by the user
- Evaluates the credit profile against three tiers:
| Tier | Credit Score | Monthly Income | Tenure | Max Amount |
|---|---|---|---|---|
| 1 | >= 700 | >= $2,000 | >= 24 months | $10,000 |
| 2 | >= 600 | >= $1,500 | any | $7,000 |
| 3 | >= 580 | any | any | $3,000 |
| Rejected | < 580 | any | any | $0 |
createLoan handles the status logic. Three outcomes are possible:
- Approved: The user asked for less than or equal to their max eligible amount. They get exactly what they asked for.
- Proposed: The user asked for more than they qualify for. The smart contract offers the max eligible amount and waits for the user to accept or decline.
- Rejected: The credit score is too low. Amount = 0.
respondToLoan lets users accept or decline a Proposed loan. If they accept, the status changes to Approved. If they decline, it changes to NotAccepted and the authorized amount is zeroed out.
Admin circuits
These access-controlled operations use the witness-derived keypair pattern. Every admin circuit starts with the same guard:
assert(contractAdmin == deriveAdminPublicKey(getUserSecret()), "Only admin can ...");
Inside the ZK proof, this enforces that the caller knows the 32-byte preimage of the public value stored in contractAdmin. The ledger value alone is useless to an attacker: they could copy it into their proof input, but they cannot supply a witness whose hash matches without knowing the secret.
ownPublicKey()?An older version of this contract used assert(ownPublicKey() == admin, "..."). That pattern is bypassable. ownPublicKey() is supplied by the prover to the circuit context — the protocol does not cross-check it against the wallet that signed the transaction. A caller can put any 32-byte value in that slot and the assertion will hold. The same bypass applies to any other check that depends on ownPublicKey(): blacklist membership, PIN-bound identity derivation, anything where the result of ownPublicKey() flows into a security-relevant decision.
The strict rule is: if your contract is not routing shielded tokens to a target wallet, do not call ownPublicKey(). Use witness-derived secrets for caller identity.
The five admin circuits are:
-
blacklistUser/removeBlacklistUser: Add or remove a derivedUserPublicKeyfrom the blacklist. The admin obtains the target'sUserPublicKeyfrom the on-chainloansmap keys (where it appears whenever the target has interacted) or from the target out-of-band. An admin cannot blacklist by wallet address —ownPublicKey()is not trusted by the contract. -
registerProvider/removeProvider: Add or remove an attestation provider's public key. -
rotateAdmin: Hand over the admin role to a new derived public key. The new admin generates their own secret locally, computesderiveAdminPublicKeyoff-chain, and shares only the resulting 32-byte value with the current admin. No private key crosses the wire.
Add the following code snippet:
export circuit blacklistUser(account: UserPublicKey): [] {
assert(contractAdmin == deriveAdminPublicKey(getUserSecret()), "Only admin can blacklist users");
blacklist.insert(disclose(account));
}
export circuit removeBlacklistUser(account: UserPublicKey): [] {
assert(contractAdmin == deriveAdminPublicKey(getUserSecret()), "Only admin can remove from blacklist");
blacklist.remove(disclose(account));
}
export circuit registerProvider(providerId: Uint<16>, providerPk: JubjubPoint): [] {
assert(contractAdmin == deriveAdminPublicKey(getUserSecret()), "Only admin can register providers");
providers.insert(disclose(providerId), disclose(providerPk));
}
export circuit removeProvider(providerId: Uint<16>): [] {
assert(contractAdmin == deriveAdminPublicKey(getUserSecret()), "Only admin can remove providers");
assert(providers.member(disclose(providerId)), "Provider not found");
providers.remove(disclose(providerId));
}
export circuit rotateAdmin(newAdmin: AdminPublicKey): [] {
assert(contractAdmin == deriveAdminPublicKey(getUserSecret()), "Only admin can rotate admin role");
contractAdmin = disclose(newAdmin);
}
PIN migration and Schnorr re-export
This final block has two circuits: PIN migration and the Schnorr challenge re-export.
Per-user identity is deriveUserPublicKey(getUserSecret(), pin), defined in the header block above. The witness secret plus the PIN produces a deterministic UserPublicKey whose bytes appear on-chain. Without knowing both the secret and the PIN, you cannot link a wallet address to a loan record — giving users an extra layer of privacy.
changePin is the most complex circuit. When a user changes their PIN, the derivation yields a new on-chain identity. But their existing loans are tied to the old identity. All loans need to migrate from the old UserPublicKey to the new one.
The catch: ZK circuits cannot loop over variable-length data. If a user has 12 loans, you cannot write for i in 0..loans.size(). The loop bound must be known at compile time. The solution is batched migration:
- Process exactly 5 loans per transaction (fixed at compile time with
for (const i of 0..5)). - Track progress in
onGoingPinMigration— it stores how far the migration has gotten. - The user calls
changePinrepeatedly until all loans are migrated. - Once done, the migration state is cleaned up.
For a user with 12 loans:
-
Call 1: Migrates loans 1–5, records progress
-
Call 2: Migrates loans 6–10, records progress
-
Call 3: Migrates loans 11–12, finds slots 13–15 empty, cleans up
While migration is in progress, requestLoan is blocked for that user (the onGoingPinMigration check).
Append this final code snippet:
export circuit changePin(oldPin: Uint<16>, newPin: Uint<16>): [] {
const oldUserPk = deriveUserPublicKey(getUserSecret(), oldPin);
const newUserPk = deriveUserPublicKey(getUserSecret(), newPin);
const disclosedOldUserPk = disclose(oldUserPk);
const disclosedNewUserPk = disclose(newUserPk);
assert(!blacklist.member(disclosedOldUserPk), "User is blacklisted");
assert(oldPin != newPin, "New PIN must be different from old PIN");
const disclosedOldPk = disclosedOldUserPk.bytes;
const disclosedNewPk = disclosedNewUserPk.bytes;
assert(loans.member(disclosedOldPk), "Old PIN does not match any user");
if (!onGoingPinMigration.member(disclosedOldPk)) {
onGoingPinMigration.insert(disclosedOldPk, 0);
}
if (!loans.member(disclosedNewPk)) {
loans.insert(disclosedNewPk, default<Map<Uint<16>, LoanApplication>>);
}
const lastMigratedSourceId: Uint<16> = onGoingPinMigration.lookup(disclosedOldPk);
const lastDestinationId: Uint<16> = loans.lookup(disclosedNewPk).size() as Uint<16>;
for (const i of 0..5) {
if (onGoingPinMigration.member(disclosedOldPk)) {
const sourceId = (lastMigratedSourceId + i + 1) as Uint<16>;
const destinationId = (lastDestinationId + i + 1) as Uint<16>;
if (loans.lookup(disclosedOldPk).member(sourceId)) {
const loan = loans.lookup(disclosedOldPk).lookup(sourceId);
loans.lookup(disclosedNewPk).insert(destinationId, disclose(loan));
loans.lookup(disclosedOldPk).remove(sourceId);
onGoingPinMigration.insert(disclosedOldPk, sourceId);
} else {
onGoingPinMigration.remove(disclosedOldPk);
if (loans.lookup(disclosedOldPk).size() == 0) {
loans.remove(disclosedOldPk);
}
}
}
}
}
export pure circuit schnorrChallenge(
ann_x: Field,
ann_y: Field,
pk_x: Field,
pk_y: Field,
msg: Vector<4, Field> ): Field {
return Schnorr_schnorrChallenge(ann_x, ann_y, pk_x, pk_y, msg);
}
schnorrChallenge re-exports the Schnorr challenge hash function as a pure circuit (no side effects, no ledger access). This must be available in the generated TypeScript so the attestation API can compute the same hash off-chain when signing.
Your smart contract file is now complete. Before moving on, here is a summary of what is private versus public:
| Data | Visibility | Why |
|---|---|---|
| 32-byte user secret | Private (witness only) | Authoritative caller identity; never leaves the user's machine |
| Credit score, income, and tenure | Private (witness only) | Never leaves the user's machine |
| Secret PIN | Private (circuit input) | Hashed with the user secret into per-user identity, never stored |
| Attestation signature | Private (ZK proof input) | Verified inside the circuit |
Derived UserPublicKey (loan map key) | Public (ledger) | Unlinkable without both the user secret and the PIN |
| Loan status and amount | Public (ledger) | The on-chain outcome |
contractAdmin, blacklist | Public (ledger) | Smart contract governance |
Create the witness function (private data provider)
The witness is the TypeScript code that provides private data to the ZK circuit at proving time. It runs on your machine, never on-chain.
Create contract/src/witnesses.ts:
import { Ledger } from "./managed/zkloan-credit-scorer/contract/index.js";
import { WitnessContext } from "@midnight-ntwrk/compact-runtime";
export type SchnorrSignature = {
announcement: { x: bigint; y: bigint };
response: bigint;
};
export type ZKLoanCreditScorerPrivateState = {
creditScore: bigint;
monthlyIncome: bigint;
monthsAsCustomer: bigint;
attestationSignature: SchnorrSignature;
attestationProviderId: bigint;
userSecretKey: Uint8Array; // 32 bytes — the caller's authentic identity
};
const TWO_248 = 452312848583266388373324160190187140051835877600158453279131187530910662656n;
export const witnesses = {
getAttestedScoringWitness: ({
privateState
}: WitnessContext<Ledger, ZKLoanCreditScorerPrivateState>): [
ZKLoanCreditScorerPrivateState,
[
{ creditScore: bigint; monthlyIncome: bigint; monthsAsCustomer: bigint },
SchnorrSignature,
bigint,
],
] => [
privateState,
[
{
creditScore: privateState.creditScore,
monthlyIncome: privateState.monthlyIncome,
monthsAsCustomer: privateState.monthsAsCustomer,
},
privateState.attestationSignature,
privateState.attestationProviderId,
],
],
getSchnorrReduction: ({
privateState
}: WitnessContext<Ledger, ZKLoanCreditScorerPrivateState>,
challengeHash: bigint,
): [ZKLoanCreditScorerPrivateState, [bigint, bigint]] => {
const q = challengeHash / TWO_248;
const r = challengeHash % TWO_248;
return [privateState, [q, r]];
},
getUserSecret: ({
privateState,
}: WitnessContext<Ledger, ZKLoanCreditScorerPrivateState>): [
ZKLoanCreditScorerPrivateState,
{ bytes: Uint8Array },
] => {
if (!privateState.userSecretKey || privateState.userSecretKey.length !== 32) {
throw new Error("getUserSecret: userSecretKey is missing or wrong length");
}
return [privateState, { bytes: privateState.userSecretKey }];
},
};
In the code above, each witness function receives a WitnessContext containing the current privateState and must return:
- The (possibly updated) private state
- The values the circuit requested
-
getAttestedScoringWitness: extracts the credit profile, attestation signature, and provider ID from private state. -
getSchnorrReduction: computes the quotient and remainder of dividing the challenge hash by 2^248 (Jubjub scalar field truncation). -
getUserSecret: returns the 32-byte secret that drives all caller identity in this contract. The contract feeds it into two pure circuits —deriveUserPublicKey(secret, pin)for per-user PIN-bound identity, andderiveAdminPublicKey(secret)for the admin role. Only the deploying admin's secret hashes to the value stored incontractAdmin; everyone else'sgetUserSecretreturns a value whose admin-derivation does not match, so admin assertions fail for them. Domain-separator strings ("zkloan:admin:pk:v1"vs"zkloan:user:pk:v1") keep the two derivations from the same secret uncorrelated. The runtime length check ensures malformed witnesses are rejected at proof time.
Create the smart contract TypeScript exports
Create contract/src/index.ts:
cat > contract/src/index.ts << 'EOF'
export * as ZKLoanCreditScorer from "./managed/zkloan-credit-scorer/contract/index.js";
export * from "./witnesses.js";
EOF
This re-exports both the generated smart contract code (from the Compact compiler) and the witness implementations.
Now create the smart contract's package.json using the following command in your terminal:
cat > contract/package.json << 'EOF'
{
"name": "zkloan-credit-scorer-contract",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.js",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./managed/zkloan-credit-scorer/contract": {
"types": "./dist/managed/zkloan-credit-scorer/contract/index.d.ts",
"import": "./dist/managed/zkloan-credit-scorer/contract/index.js",
"default": "./dist/managed/zkloan-credit-scorer/contract/index.js"
}
},
"scripts": {
"compact": "compact compile src/zkloan-credit-scorer.compact src/managed/zkloan-credit-scorer",
"test": "vitest run",
"test:compile": "npm run compact && vitest run",
"build": "rm -rf dist && tsc --project tsconfig.build.json && cp -Rf ./src/managed ./dist/managed && cp ./src/zkloan-credit-scorer.compact ./src/schnorr.compact ./dist"
}
}
EOF
Create contract/tsconfig.json:
cat > contract/tsconfig.json << 'EOF'
{
"include": ["src/**/*.ts"],
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"lib": ["ESNext"],
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strict": true,
"isolatedModules": true,
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
EOF
Create contract/tsconfig.build.json:
cat > contract/tsconfig.build.json << 'EOF'
{
"extends": "./tsconfig.json",
"exclude": ["src/test/**/*.ts"],
"compilerOptions": {}
}
EOF
Compile the smart contract
Install all dependencies from the project root:
npm install
Compile the Compact smart contract:
cd contract
npm run compact
This creates the src/managed/zkloan-credit-scorer/ directory containing:
-
contract/— Generated TypeScript implementation of the smart contract -
keys/— Proving and verifying keys for each circuit -
zkir/— ZK intermediate representation files -
compiler/— Compiler metadata
Now build the TypeScript:
npm run build
cd ..
At this point, the smart contract package is compiled and ready to be consumed by the CLI and attestation API.
What you built in Part 1
This part covered the foundation of the ZK loan application:
- Schnorr signature module: A Compact module that verifies cryptographic signatures inside a ZK circuit, ensuring credit data comes from a trusted source.
- Loan smart contract: The full Compact smart contract with loan request logic, tiered eligibility evaluation, admin controls, PIN-based identity derivation, and batched PIN migration.
- Witness implementation: TypeScript code that feeds private credit data into the ZK circuit at proving time, without exposing it on-chain.
- TypeScript exports and compilation: Package configuration, compiler output, and the generated ZK circuits, keys, and bindings.
The smart contract has been compiled and is ready. No credit data touches the blockchain; only the loan outcome does.
Next steps
The next part builds the off-chain infrastructure that makes the smart contract functional:
- Attestation API: A REST server that signs credit data with Schnorr signatures, acting as the trusted data provider that the smart contract verifies against.
- Attestation flow: A walkthrough of how the user, attestation API, and Midnight Network interact end-to-end.
- Docker setup for the proof server: Configuring the local proof server that generates ZK proofs for your transactions.