For the complete documentation index, see llms.txt
ZK Loan contract
This part walks through the contract in examples/zk-loan/contract/: the Schnorr module
schnorr.compact, the loan contract zk-loan.compact, and the TypeScript witnesses in
witnesses.ts.
Prerequisites
- The set-up steps of this tutorial, so you have midnight-examples installed and compiled.
- The Compact toolchain and Node.js 22.
Open examples/zk-loan/contract/zk-loan.compact in your editor and keep it beside this page.
The problem
Traditional lending asks a borrower to hand their financial data to a lender, who stores it and shares it. A ZK proof turns that around. Instead of showing the data, the borrower proves a statement about it: "my credit score is at least 700 and my income is at least 2,000 a month". The verifier learns that the statement is true, and nothing else.
The contract has to solve two problems to make that work:
- The data has to be honest. The credit profile comes from a witness, which runs on the borrower's machine. A borrower could return any score they like. So a trusted provider signs the real figures, and the contract verifies the signature inside the proof.
- The borrower has to be someone. Each loan is recorded against an identity, and some circuits are admin-only. Both identities come from a secret in the caller's private state, never from the wallet that submits the transaction.
The loan flow in order:
- The admin deploys the contract and registers the attestation provider's public key.
- A borrower asks the provider to sign their credit profile, bound to their identity.
- The borrower calls
requestLoan. Inside the proof, the contract verifies the signature, scores the profile against three tiers, and records the loan's status and amount. - If the borrower asked for more than their tier allows, the loan is
Proposedat the tier's maximum, and the borrower accepts or declines it withrespondToLoan.
Public and private data
| Data | Visibility | Why |
|---|---|---|
| 32-byte user secret | Private (witness) | The caller's identity. It never leaves their machine |
| Credit score, income, tenure | Private (witness) | Only checked inside the proof |
| Secret PIN | Private (circuit argument) | Hashed with the secret into the borrower's identity, never stored |
| Attestation signature | Private (witness) | Verified inside the proof |
| Derived user public key | Public (ledger) | The key of the borrower's loans. It reveals neither the secret nor the PIN |
| Loan status and authorized amount | Public (ledger) | The outcome |
contractAdmin, providers, blacklist | Public (ledger) | Contract governance |
The Schnorr module
contract/schnorr.compact verifies a Schnorr signature
on the Jubjub curve, Midnight's native internal curve, inside a circuit. It is a module, not a
contract: it has no pragma or ledger of its own, and zk-loan.compact imports it.
The module is a temporary polyfill. A Schnorr verifier is planned for the Compact standard library, and once it ships it replaces this module.
A signature is an announcement point R and a response scalar s:
export struct SchnorrSignature {
announcement: JubjubPoint; // R
response: Field; // s
}
schnorrVerify starts by hashing everything the signature commits to: the announcement, the
public key, and the message. Hashing the public key and announcement, not only the message,
prevents key-substitution attacks. It uses transientHash, not the persistentHash you met in
Private Party. transientHash is the circuit-efficient hash: it returns
a Field, and its output isn't guaranteed to stay the same across upgrades, so it suits checks
inside a proof rather than data kept on the ledger.
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 }
);
Witness-assisted reduction
transientHash returns a value in the BLS12-381 scalar field, about 255 bits, but ecMul needs
a scalar below the Jubjub order, about 252 bits. So the challenge is truncated to 248 bits.
Division is expensive in a circuit, so a witness does it off-chain and the circuit only checks the
answer:
// The witness provides the quotient and remainder of dividing the challenge
// hash by 2^248, so it can be truncated to fit in Jubjub's scalar field.
// Division is cheap off-chain and expensive in-circuit; the circuit only
// CHECKS the result. The quotient is Uint<7> (not Field) so the check below
// holds over the integers rather than modulo the field prime — see the range
// assert in schnorrVerify.
witness getSchnorrReduction(challengeHash: Field): [Uint<7>, Uint<248>];
// Truncate the challenge to 248 bits via witness-assisted division by 2^248.
// The witness provides (quotient, remainder) such that cFull == q * 2^248 + r
// where r : Uint<248>, guaranteeing r < 2^248 < Jubjub subgroup order.
// This is needed because ecMul requires scalars < JUBJUB_ORDER (~2^252.4),
// but transientHash returns values in BLS12-381's scalar field (~2^255).
const TWO_248: Field =
452312848583266388373324160190187140051835877600158453279131187530910662656 as Field;
const [q, cTruncated] = getSchnorrReduction(cFull);
// Bound the quotient so the reduction equation holds over the integers, not
// modulo the field prime. Without this, q : Field is unconstrained and a
// prover could pick any cTruncated (i.e. any challenge) and solve for a
// matching q, forging the signature. floor((BLS12-381 scalar prime - 1) / 2^248)
// == 115, so an honest quotient is always in [0, 115]. The zk-loan
// simulator test "rejects a forged attestation ..." is the regression test.
assert(disclose(q) < 116, "Schnorr quotient out of range");
assert(disclose(q) * TWO_248 + (disclose(cTruncated) as Field) == cFull,
"Invalid challenge reduction"
);
const c: Field = disclose(cTruncated) as Field;
This is a common ZK pattern: let the prover compute something expensive, then verify the result cheaply in the circuit. The check has to pin the answer down completely, though.
The reduction equation alone is not enough. Circuit arithmetic is modulo the BLS12-381 scalar
prime, and 2^248 is invertible in that field. If the quotient were an unconstrained Field, a
malicious prover could pick any truncated challenge and solve for a matching quotient, which
would let them forge a signature without the provider's secret key. Typing the quotient as
Uint<7> and asserting q < 116 (the largest honest quotient is 115) forces the equation to hold
over the integers, which ties the challenge to the hash. Witness values are prover-controlled
inputs: constrain them until only honest values satisfy the circuit.
The simulator suite has a regression test for this: "rejects a forged attestation that exploits an unconstrained challenge quotient" builds exactly that forgery and checks the circuit rejects it.
The verification equation
With the challenge c in hand, the circuit checks the Schnorr equation s*G == R + c*pk:
// s*G == R + c*pk
const lhs: JubjubPoint = ecMulGenerator(response);
const rhs: JubjubPoint = ecAdd(announcement, ecMul(pk, c));
// Compare coordinates, not the points: `==` on the opaque JubjubPoint
// compiles to JS reference equality.
assert(jubjubPointX(lhs) == jubjubPointX(rhs) && jubjubPointY(lhs) == jubjubPointY(rhs),
"Invalid attestation signature");
Compare the coordinates, not the points. JubjubPoint is opaque in Compact, and == between two
points compiles to JavaScript reference equality, so it would never hold for freshly computed
points.
The challenge as a pure circuit
The signer off-chain must compute exactly the challenge the circuit computes. Rather than
re-implementing the hash in TypeScript, the module exports it as a pure circuit: no ledger
access and no witnesses, so the compiler also generates a TypeScript version of it.
// Pure helper exposing the challenge hash to TypeScript (via the re-export in
// zk-loan.compact) so off-chain signers compute exactly what the circuit does.
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 the full hash. Callers must truncate to 248 bits (mod 2^248) to
// match schnorrVerify's challenge reduction.
return cFull;
}
Types, ledger, and constructor
zk-loan.compact pins the language version and imports the standard library and the Schnorr
module. prefix Schnorr_ namespaces every name the module exports, so schnorrVerify becomes
Schnorr_schnorrVerify:
pragma language_version 0.23;
import CompactStandardLibrary;
// schnorr.compact sits next to this file; `prefix Schnorr_` namespaces every
// exported name (Schnorr_schnorrVerify, Schnorr_SchnorrSignature, ...).
import "schnorr" prefix Schnorr_;
// Re-export the signature struct so it appears in the generated TypeScript
// types — the witness returns one and the tests construct them.
export { Schnorr_SchnorrSignature };
The data types come next:
// Enum values are serialized as their index: Approved=0, Rejected=1,
// Proposed=2, NotAccepted=3. Tests compare against these numbers.
export enum LoanStatus {
Approved, // approved outright, or accepted after a Proposed offer
Rejected, // applicant did not meet minimum tier eligibility
Proposed, // request exceeded the eligible tier — awaiting the user's decision
NotAccepted, // user declined a Proposed offer
}
// The only per-loan data that ever reaches the ledger.
export struct LoanApplication {
authorizedAmount: Uint<16>;
status: LoanStatus;
}
// The private credit profile. Not exported: it only ever flows from the
// witness into `evaluateApplicant` and is never written to the ledger.
struct Applicant {
creditScore: Uint<16>;
monthlyIncome: Uint<16>;
monthsAsCustomer: Uint<16>;
}
LoanStatusandLoanApplicationare exported.exportdoesn't make them public. It generates TypeScript bindings, so the DApp can read and construct them. They are public anyway, becauseloanson the ledger stores them, and all ledger state is public.Applicantis not exported, so it gets no TypeScript binding. It stays private because it is only ever witness and circuit-internal data, never written to the ledger, not because it isn't exported.
The three key types are each a new type over Bytes<32>:
// Distinct nominal types over Bytes<32> so a user key cannot be passed where an
// admin key is expected (and vice versa) without an explicit cast.
export new type UserSecretKey = Bytes<32>;
export new type UserPublicKey = Bytes<32>;
export new type AdminPublicKey = Bytes<32>;
new type X = T; creates a nominal type with the same representation as T. The three share
the same 32-byte layout, but the compiler won't accept a UserPublicKey where a circuit expects an
AdminPublicKey or a bare Bytes<32>. Bare Bytes<32> everywhere would compile too, but it would
let a caller swap a user key for an admin key, the confusion this contract's security depends on
avoiding.
A new type adds no wrapper. In the generated TypeScript, a UserPublicKey is a plain
Uint8Array. In Compact you go into the type with value as UserPublicKey and out with
pk as Bytes<32>.
Ledger state
// Derived user public keys that may not request/respond/change PIN.
export ledger blacklist: Set<UserPublicKey>;
// Derived user public key -> (loanId -> loan). Loan ids start at 1.
export ledger loans: Map<Bytes<32>, Map<Uint<16>, LoanApplication>>;
// Old user public key -> last source loanId already migrated by `changePin`.
// Presence of a key means a migration is in progress for that identity.
export ledger onGoingPinMigration: Map<Bytes<32>, Uint<16>>;
// Derived admin public key. Rotatable via `rotateAdmin`.
export ledger contractAdmin: AdminPublicKey;
// Registered attestation providers: providerId -> Jubjub public key.
export ledger providers: Map<Uint<16>, JubjubPoint>;
loansis a nested map. The outer key is a borrower's derived public key, the inner key a loan ID, so each borrower can have several loans.providersmaps a provider ID to the provider's Jubjub public key. Attestation signatures are verified against these.contractAdminholds the derived admin public key, a hash of the admin's secret. The ledger never sees the secret itself.blacklistholds derived user public keys, not wallet addresses.onGoingPinMigrationtracks a PIN change in progress. See Changing the PIN.
The constructor and witnesses
The deployer becomes admin:
// The deployer becomes admin: their derived admin public key (NOT their secret)
// is pinned on-chain. `disclose` is required because the value is derived from
// witness data and is being written to public state.
constructor() {
contractAdmin = disclose(deriveAdminPublicKey(getUserSecret()));
}
Two witnesses supply the private data. getAttestedScoringWitness returns the credit profile,
the provider's signature over it, and which provider signed. getUserSecret returns the 32-byte
secret behind every identity in the contract:
// Witnesses run off-chain on the prover. Their outputs are UNTRUSTED inputs to
// the circuit: every value is either verified (the Schnorr signature over the
// profile) or only used through a hash whose preimage must be known (the secret).
witness getAttestedScoringWitness(): [Applicant, Schnorr_SchnorrSignature, Uint<16>];
witness getUserSecret(): UserSecretKey;
Identity from one secret
Two pure circuits turn the secret into the contract's two kinds of identity:
// Per-user identity, PIN-rotatable. Changing the PIN yields a new derived
// public key, but not an unlinkable one: `changePin` names the old and new
// keys in the same transaction. Used as the loan map key and the blacklist
// key. `pure` so the same derivation is exported to TypeScript
// (`pureCircuits.deriveUserPublicKey`) for off-chain lookups.
// The string literal is a domain separator: it keeps this hash from ever
// colliding with `deriveAdminPublicKey` for the same secret.
export pure circuit deriveUserPublicKey(sk: UserSecretKey, pin: Uint<16>): UserPublicKey {
const pinBytes = persistentHash<Uint<16>>(pin);
return persistentHash<[Bytes<17>, Bytes<32>, UserSecretKey]>([
"zkloan:user:pk:v1",
pinBytes,
sk
]) as UserPublicKey;
}
// 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 persistentHash<[Bytes<18>, UserSecretKey]>([
"zkloan:admin:pk:v1",
sk
]) as AdminPublicKey;
}
deriveUserPublicKey(sk, pin)is the borrower's identity. It is bound to a PIN, so a new PIN gives the borrower a new key. It isn't an unlinkable fresh start, though:changePinnames the old and new keys in the same transaction, so anyone watching the chain can link them.deriveAdminPublicKey(sk)is the admin identity. It has no PIN, so the admin role survives the admin's PIN changes.
The string at the start of each tuple is a domain separator. It keeps the two hashes of the same
secret from ever colliding or being correlated. Count its bytes exactly: "zkloan:user:pk:v1" is
17 bytes and "zkloan:admin:pk:v1" is 18. persistentHash hashes the whole tuple, including the
UserSecretKey value directly.
Both circuits are exported so off-chain code can derive the same keys. The tests call
pureCircuits.deriveUserPublicKey to look up a borrower's loans, and
pureCircuits.deriveAdminPublicKey to check contractAdmin.
Requesting a loan
requestLoan is the entry point a borrower calls:
// Entry point. Derives the caller's identity, checks it is allowed to borrow,
// runs the private evaluation, and records only the outcome.
export circuit requestLoan(amountRequested: Uint<16>, secretPin: Uint<16>): [] {
assert(amountRequested > 0, "Loan amount must be greater than zero");
const requesterPubKey = deriveUserPublicKey(getUserSecret(), secretPin);
// The derived key is the public loan-map key, so it must be disclosed. The
// PIN and secret stay private: only their hash is revealed.
const disclosedRequesterPubKey = disclose(requesterPubKey);
assert(!blacklist.member(disclosedRequesterPubKey), "Requester is blacklisted");
assert(!onGoingPinMigration.member(disclosedRequesterPubKey as Bytes<32>),
"PIN migration is in progress for this user");
// The attestation is signed over this hash, binding it to ONE identity:
// a signature issued to someone else fails verification (anti-replay).
const userPubKeyHash = transientHash<Bytes<32>>(disclosedRequesterPubKey as Bytes<32>);
const [topTierAmount, status] = evaluateApplicant(userPubKeyHash);
// Only the tier outcome is disclosed — never the score/income/tenure.
const disclosedTopTierAmount = disclose(topTierAmount);
const disclosedStatus = disclose(status);
createLoan(disclosedRequesterPubKey as Bytes<32>, amountRequested, disclosedTopTierAmount, disclosedStatus);
}
- Derive the caller's identity from their secret and PIN. The witness secret is the only caller identity the contract trusts.
disclose()the derived key. It is the public key of the loans map, and the compiler requires an explicit disclosure before a witness-derived value reaches a ledger read or write.- Check that the borrower isn't blacklisted or in the middle of a PIN change.
- Hash the identity into
userPubKeyHashand run the private evaluation. The provider signed this hash along with the profile, so a signature issued to someone else fails. - Disclose only the outcome, the tier amount and status, and record the loan.
disclose() doesn't publish anything by itself. It is a compile-time annotation that marks a
witness-derived value as allowed to leave the circuit. The value becomes public when a ledger read
or write uses it. An exported circuit's return value needs disclose() too, but it isn't
published: the transaction carries only a randomized commitment to the call's inputs and outputs.
The credit score, income, and tenure are never disclosed and never reach the ledger, so they stay
private.
The private evaluation
evaluateApplicant isn't exported, so it can only run as part of requestLoan's proof:
// Private credit evaluation. Not exported: it only runs as part of
// `requestLoan`'s proof. The witness profile is trusted ONLY after the
// provider's signature over it has been verified in-circuit.
circuit evaluateApplicant(userPubKeyHash: Field): [Uint<16>, LoanStatus] {
const [profile, signature, providerId] = getAttestedScoringWitness();
// The provider must be registered on-chain by the admin. Disclosing the id
// reveals WHICH provider attested, but nothing about the profile.
assert(providers.member(disclose(providerId)), "Attestation provider not registered");
const providerPk = providers.lookup(disclose(providerId));
// Signed message: [creditScore, monthlyIncome, monthsAsCustomer, userPubKeyHash].
// Must match, element for element, what the provider signed off-chain.
const msg: Vector<4, Field> = [
profile.creditScore as Field,
profile.monthlyIncome as Field,
profile.monthsAsCustomer as Field,
userPubKeyHash
];
// Fails the whole transaction if the signature is not valid for this
// provider over this exact message — so a tampered profile cannot pass.
Schnorr_schnorrVerify<4>(msg, signature, providerPk);
// Tier 1: best applicants, max 10,000
if (profile.creditScore >= 700 && profile.monthlyIncome >= 2000 && profile.monthsAsCustomer >= 24) {
return [10000, LoanStatus.Approved];
}
// Tier 2: good applicants, max 7,000
else if (profile.creditScore >= 600 && profile.monthlyIncome >= 1500) {
return [7000, LoanStatus.Approved];
}
// Tier 3: basic eligibility, max 3,000
else if (profile.creditScore >= 580) {
return [3000, LoanStatus.Approved];
}
// None of the above: rejected.
else {
return [0, LoanStatus.Rejected];
}
}
It takes the profile, signature, and provider ID from the witness, checks the provider is
registered, and rebuilds the signed message element for element. Schnorr_schnorrVerify fails the
whole transaction if the signature isn't valid for that provider over that exact message. Only
then is the profile trusted, and scored:
| Tier | Credit score | Monthly income | Tenure | Maximum |
|---|---|---|---|---|
| 1 | at least 700 | at least 2,000 | at least 24 months | 10,000 |
| 2 | at least 600 | at least 1,500 | any | 7,000 |
| 3 | at least 580 | any | any | 3,000 |
| Rejected | below 580 | any | any | 0 |
Recording the loan
// Writes the loan record. All arguments are already disclosed by the caller.
circuit createLoan(requester: Bytes<32>, amountRequested: Uint<16>, topTierAmount: Uint<16>, status: LoanStatus): [] {
const authorizedAmount = amountRequested > topTierAmount ? topTierAmount : amountRequested;
// Final status:
// - Rejected (not eligible) stays Rejected
// - Approved and requested <= eligible stays Approved
// - Approved but requested > eligible becomes Proposed (user must accept the lower amount)
const finalStatus = status == LoanStatus.Rejected
? LoanStatus.Rejected
: (amountRequested > topTierAmount ? LoanStatus.Proposed : LoanStatus.Approved);
const loan = LoanApplication {
authorizedAmount: authorizedAmount,
status: finalStatus,
};
// Nested ADTs must be initialized before use: insert an empty inner map the
// first time this identity borrows.
if (!loans.member(requester)) {
loans.insert(requester, default<Map<Uint<16>, LoanApplication>>);
}
// Auto-incrementing loan id = current count + 1 (ids start at 1). The
// assert keeps the cast to Uint<16> from overflowing.
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));
}
There are three outcomes:
- Approved: the borrower asked for no more than their tier's maximum, and gets what they asked for.
- Proposed: the borrower asked for more. The loan records the tier's maximum and waits for the borrower's answer.
- Rejected: the profile didn't meet any tier. The amount is 0.
Responding to a proposal
respondToLoan resolves a Proposed loan:
// Resolve a Proposed loan. Only the owner — whose secret + PIN derive to the
// loan's map key — can find it, so no separate ownership check is needed.
export circuit respondToLoan(loanId: Uint<16>, secretPin: Uint<16>, accept: Boolean): [] {
const requesterPubKey = deriveUserPublicKey(getUserSecret(), secretPin);
const disclosedRequesterPubKey = disclose(requesterPubKey);
const disclosedPubKey = disclosedRequesterPubKey as Bytes<32>;
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");
// Accept keeps the proposed (capped) amount; decline zeroes it.
const updatedLoan = accept
? LoanApplication { authorizedAmount: existingLoan.authorizedAmount, status: LoanStatus.Approved }
: LoanApplication { authorizedAmount: 0, status: LoanStatus.NotAccepted };
loans.lookup(disclosedPubKey).insert(disclosedLoanId, disclose(updatedLoan));
}
There's no separate ownership check. The loan is looked up under the identity derived from the caller's secret and PIN, so only its owner can find it.
Admin circuits
Every admin circuit opens with the same guard:
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));
}
Inside the proof, contractAdmin == deriveAdminPublicKey(getUserSecret()) forces the caller to
know the secret whose hash is stored in contractAdmin. Anyone can read contractAdmin off the
ledger, but without the secret they can't produce a witness that hashes to it.
blacklistUser takes a derived UserPublicKey, not a wallet address. The admin gets it from the
keys of the loans map, where it appears once the borrower has a loan.
ownPublicKey()?An older version of this contract checked ownPublicKey() == admin. That check can be bypassed:
the prover supplies ownPublicKey() to the circuit, and the protocol doesn't cross-check it
against the wallet that signs the transaction, so a caller can put any value there. The same goes
for any security decision that depends on it. Unless a contract is sending shielded tokens to a
wallet, don't use ownPublicKey(); derive caller identity from a witness secret instead.
rotateAdmin hands the role to a new admin:
// Hand the admin role to a new derived public key. The caller proves they hold
// the current admin secret; the new admin is passed in as a PUBLIC key the next
// admin already controls (they compute it off-chain with
// `pureCircuits.deriveAdminPublicKey`). No private key ever leaves either party.
export circuit rotateAdmin(newAdmin: AdminPublicKey): [] {
assert(contractAdmin == deriveAdminPublicKey(getUserSecret()), "Only admin can rotate admin role");
contractAdmin = disclose(newAdmin);
}
The new admin generates their own secret, computes deriveAdminPublicKey off-chain, and shares
only the resulting public key. No secret changes hands.
Changing the PIN
A new PIN derives a new identity, but the borrower's loans are recorded under the old one, so
changePin has to move them. A circuit can't loop over a variable number of loans: every loop
bound must be known at compile time. So changePin migrates a fixed batch of five loans per call,
and keeps a cursor in onGoingPinMigration:
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 as Bytes<32>;
const disclosedNewPk = disclosedNewUserPk as Bytes<32>;
assert(loans.member(disclosedOldPk), "Old PIN does not match any user");
// First batch: start the cursor at 0 and make sure the destination exists.
if (!onGoingPinMigration.member(disclosedOldPk)) {
onGoingPinMigration.insert(disclosedOldPk, 0);
}
if (!loans.member(disclosedNewPk)) {
loans.insert(disclosedNewPk, default<Map<Uint<16>, LoanApplication>>);
}
// Migrated loans are appended after any loans the new identity already has.
const lastMigratedSourceId: Uint<16> = onGoingPinMigration.lookup(disclosedOldPk);
const lastDestinationId: Uint<16> = loans.lookup(disclosedNewPk).size() as Uint<16>;
The first call starts the cursor at 0 and creates the borrower's new entry. Then each call moves up to five loans:
// Process up to 5 loans per transaction. The bound is a compile-time
// constant, so the loop is unrolled into the circuit.
for (const i of 0..5) {
// The migration may have completed in an earlier iteration of this loop.
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)) {
// Move the loan and advance the cursor.
const loan = loans.lookup(disclosedOldPk).lookup(sourceId);
loans.lookup(disclosedNewPk).insert(destinationId, disclose(loan));
loans.lookup(disclosedOldPk).remove(sourceId);
onGoingPinMigration.insert(disclosedOldPk, sourceId);
} else {
// No more loans: clear the migration flag and drop the empty
// old identity entirely.
onGoingPinMigration.remove(disclosedOldPk);
if (loans.lookup(disclosedOldPk).size() == 0) {
loans.remove(disclosedOldPk);
}
}
}
}
}
For a borrower with 12 loans:
- Call 1 moves loans 1–5 and records the cursor at 5.
- Call 2 moves loans 6–10.
- Call 3 moves loans 11 and 12, finds slot 13 empty, clears the cursor, and removes the old, now-empty identity.
While the cursor exists, requestLoan rejects the old identity, so no new loan can land behind
the migration.
Re-exporting the challenge
The contract re-exports the module's challenge hash, so it appears in the generated TypeScript
pureCircuits, where the attestation API's signer calls it:
// Re-export schnorrChallenge from the schnorr module so it is available in the
// generated TypeScript `pureCircuits` — the off-chain signer (see
// src/test/utils/test-data.ts) must compute the exact same challenge hash.
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);
}
The witnesses
contract/witnesses.ts implements the two witnesses, plus the module's getSchnorrReduction.
The private state holds the signed profile and the user secret:
export type ZkLoanPrivateState = {
creditScore: bigint;
monthlyIncome: bigint;
monthsAsCustomer: bigint;
attestationSignature: SchnorrSignature;
attestationProviderId: bigint;
userSecretKey: Uint8Array;
};
SchnorrSignature mirrors the generated Schnorr_SchnorrSignature: a JubjubPoint crosses into
TypeScript as its { x, y } coordinates. None of these fields ever reaches the ledger.
A helper builds a private state with a secret and an empty, unsigned profile. That is enough to
deploy and to call the admin circuits. requestLoan also needs a signed profile, which the
tests get from the attestation API.
// A private state holding only a user secret and an empty (unsigned) profile.
// Enough to deploy and to call admin circuits; `requestLoan` additionally needs
// a signed profile (see `createCustomSignedProfile` in the test utilities).
export const createZkLoanPrivateState = (userSecretKey: Uint8Array): ZkLoanPrivateState => ({
creditScore: 0n,
monthlyIncome: 0n,
monthsAsCustomer: 0n,
attestationSignature: { announcement: { x: 0n, y: 0n }, response: 0n },
attestationProviderId: 0n,
userSecretKey,
});
Each witness receives a WitnessContext and returns a tuple of the next private state and the
value the circuit asked for:
// The `Witnesses<ZkLoanPrivateState>` annotation is load-bearing: leaving it to
// inference collapses the private-state type parameter to `never` and breaks
// the deploy/call overloads.
export const witnesses: Witnesses<ZkLoanPrivateState> = {
// Hands the circuit the private profile, the provider's signature over it,
// and which provider signed. The circuit re-derives the message and verifies
// the signature before trusting any of it.
getAttestedScoringWitness: ({ privateState }) => [
privateState,
[
{
creditScore: privateState.creditScore,
monthlyIncome: privateState.monthlyIncome,
monthsAsCustomer: privateState.monthsAsCustomer,
},
privateState.attestationSignature,
privateState.attestationProviderId,
],
],
// Witness-assisted division: cheap here, and the circuit checks
// `q * 2^248 + r == challengeHash` with q bounded, so a dishonest answer is
// rejected rather than trusted.
getSchnorrReduction: ({ privateState }, challengeHash) => {
const q = challengeHash / TWO_248;
const r = challengeHash % TWO_248;
return [privateState, [q, r]];
},
// The single source of caller identity. Validate the length so a malformed
// private state fails loudly at proof time rather than as an opaque
// circuit error.
getUserSecret: ({ privateState }) => {
if (!privateState.userSecretKey || privateState.userSecretKey.length !== 32) {
throw new Error('getUserSecret: userSecretKey is missing or wrong length');
}
return [privateState, privateState.userSecretKey];
},
};
getAttestedScoringWitnessreturns the profile, the signature, and the provider ID. The circuit verifies the signature before it trusts any of them.getSchnorrReductiondivides the challenge hash by 2^248. The honest quotient is always between 0 and 115, so it passes the circuit's range check.getUserSecretreturns the bare secret, aUint8Array, becauseUserSecretKeyis anew typeoverBytes<32>. Its length check makes a malformed private state fail loudly while the proof is built, rather than as an opaque circuit error.
Annotate the object as Witnesses<ZkLoanPrivateState>. Left to inference, the private-state type
parameter collapses to never, and the MidnightJS deploy and call functions no longer type-check.
contract/index.ts exports the compiled contract, CompiledZkLoanContract, with these witnesses
attached, for the tests and the attestation API to import.
Compile
From examples/zk-loan:
yarn compile
This runs compact compile contract/zk-loan.compact contract/managed/zk-loan. The compiler finds
schnorr.compact next to the contract and generates a proving key for each circuit:
Compiling 8 circuits:
circuit "blacklistUser" (k=13, rows=2579)
circuit "changePin" (k=14, rows=14024)
circuit "registerProvider" (k=13, rows=2340)
circuit "removeBlacklistUser" (k=13, rows=2579)
circuit "removeProvider" (k=13, rows=2320)
circuit "requestLoan" (k=13, rows=7998)
circuit "respondToLoan" (k=13, rows=6222)
circuit "rotateAdmin" (k=13, rows=2579)
Overall progress [====================] 8/8
Eight circuits, one for each exported impure circuit. The pure circuits deriveUserPublicKey,
deriveAdminPublicKey, and schnorrChallenge don't need one, and evaluateApplicant and
createLoan are compiled into requestLoan. changePin is the largest because its five-step
loop is unrolled into the circuit.
The contract has a fast simulator suite too, which needs no network. It runs every circuit, including the forged-attestation regression test, in memory:
yarn test:sim
Next
The attestation API is the provider this contract trusts: it signs credit data with the same challenge hash, so the signatures verify inside the proof.