Skip to main content
For the complete documentation index, see llms.txt

ZK Loan contract

이 파트에서는 examples/zk-loan/contract/에 있는 컨트랙트를 하나씩 살펴봅니다. Schnorr 모듈 schnorr.compact, 대출 컨트랙트 zk-loan.compact, 그리고 witnesses.ts의 TypeScript witness입니다.

Prerequisites​

편집기에서 examples/zk-loan/contract/zk-loan.compact를 열어 이 페이지와 나란히 두세요.

The problem​

기존 대출은 차입자가 금융 데이터를 대출 기관에 넘기도록 요구하고, 대출 기관은 그 데이터를 저장하고 공유합니다. ZK proof는 이 구조를 뒤집습니다. 차입자는 데이터를 보여 주는 대신 데이터에 관한 명제를 증명합니다. "내 신용 점수는 700점 이상이고 소득은 월 2,000 이상이다"처럼요. verifier는 그 명제가 참이라는 사실만 알 뿐, 그 외에는 아무것도 알지 못합니다.

이를 성립시키려면 컨트랙트가 두 가지 문제를 풀어야 합니다:

  • 데이터가 정직해야 한다. 신용 프로필은 차입자의 기기에서 실행되는 witness에서 나옵니다. 차입자는 원하는 점수를 무엇이든 반환할 수 있습니다. 그래서 신뢰할 수 있는 provider가 실제 수치에 서명하고, 컨트랙트는 proof 안에서 그 서명을 검증합니다.
  • 차입자에게 신원이 있어야 한다. 각 대출은 하나의 신원에 기록되고, 일부 circuit은 admin 전용입니다. 두 신원 모두 caller의 private state에 있는 secret에서 나오며, 트랜잭션을 제출하는 지갑에서 나오지 않습니다.

대출 흐름을 순서대로 보면:

  1. admin이 컨트랙트를 배포하고 attestation provider의 public key를 등록합니다.
  2. 차입자가 자신의 신원에 묶인 신용 프로필에 서명해 달라고 provider에게 요청합니다.
  3. 차입자가 requestLoan을 호출합니다. 컨트랙트는 proof 안에서 서명을 검증하고, 프로필을 세 등급에 맞춰 평가한 뒤, 대출의 status와 금액을 기록합니다.
  4. 차입자가 자기 등급이 허용하는 한도보다 많이 요청했다면, 대출은 그 등급의 최대 금액으로 Proposed가 되고, 차입자는 respondToLoan으로 수락하거나 거절합니다.

Public and private data​

데이터가시성이유
32바이트 user secretPrivate (witness)caller의 신원. 사용자의 기기를 벗어나지 않음
신용 점수, 소득, 재직 기간Private (witness)proof 안에서만 검사됨
Secret PINPrivate (circuit 인자)secret과 함께 해시되어 차입자의 신원이 되며, 저장되지 않음
Attestation signaturePrivate (witness)proof 안에서 검증됨
도출된 user public keyPublic (ledger)차입자 대출의 키. secret도 PIN도 드러내지 않음
대출 status와 승인 금액Public (ledger)결과
contractAdmin, providers, blacklistPublic (ledger)컨트랙트 거버넌스

The Schnorr module​

contract/schnorr.compact는 Midnight의 고유 내부 곡선인 Jubjub 곡선 위의 Schnorr signature를 circuit 안에서 검증합니다. contract가 아니라 module이며, 자체 pragma나 ledger가 없고 zk-loan.compact가 이를 import합니다.

info

이 모듈은 임시 폴리필입니다. Schnorr verifier가 Compact standard library에 추가될 예정이며, 출시되면 이 모듈을 대체합니다.

서명은 announcement 점 R과 response 스칼라 s로 이루어집니다:

contract/schnorr.compact
export struct SchnorrSignature {
announcement: JubjubPoint; // R
response: Field; // s
}

schnorrVerify는 먼저 서명이 commit하는 모든 것을 해시합니다. announcement, public key, 그리고 메시지입니다. 메시지뿐 아니라 public key와 announcement까지 해시하면 key 치환 공격을 막을 수 있습니다. 여기서는 Private Party에서 본 persistentHash가 아니라 transientHash를 씁니다. transientHash는 circuit에 효율적인 해시입니다. Field를 반환하고 그 출력이 업그레이드를 거쳐도 동일하게 유지된다는 보장이 없으므로, ledger에 보관하는 데이터보다는 proof 안의 검사에 적합합니다.

contract/schnorr.compact
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는 약 255비트인 BLS12-381 스칼라 field의 값을 반환하지만, ecMul은 약 252비트인 Jubjub 차수보다 작은 스칼라가 필요합니다. 그래서 challenge를 248비트로 잘라냅니다. circuit에서 나눗셈은 비용이 크므로, witness가 오프체인에서 나눗셈을 하고 circuit은 그 답만 검사합니다:

contract/schnorr.compact
// 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>];
contract/schnorr.compact
// 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;

이는 ZK에서 흔한 패턴입니다. 비용이 큰 계산은 prover가 하게 하고, 그 결과는 circuit에서 저렴하게 검증합니다. 다만 그 검사가 답을 완전히 못 박아 두어야 합니다.

Constrain witness values fully

reduction 등식만으로는 충분하지 않습니다. circuit 산술은 BLS12-381 스칼라 소수를 법으로 하는 모듈러 연산이고, 2^248은 그 field에서 역원을 가집니다. 따라서 몫이 제약 없는 Field라면, 악의적인 prover는 잘라낸 challenge를 아무 값이나 고른 뒤 그에 맞는 몫을 풀어낼 수 있고, 그러면 provider의 비밀 키 없이도 서명을 위조할 수 있습니다. 몫을 Uint<7>로 타이핑하고 q < 116을 assert하면(정직한 몫의 최댓값은 115) 등식이 정수 위에서 성립하도록 강제되어, challenge가 해시에 묶입니다. witness 값은 prover가 통제하는 입력이므로, 정직한 값만 circuit을 만족할 때까지 제약해야 합니다.

시뮬레이터 스위트에는 이에 대한 회귀 테스트가 있습니다. "rejects a forged attestation that exploits an unconstrained challenge quotient"는 바로 그 위조를 만들어 circuit이 이를 거부하는지 확인합니다.

The verification equation​

challenge c를 얻고 나면, circuit은 Schnorr 등식 s*G == R + c*pk를 검사합니다:

contract/schnorr.compact
// 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");

점이 아니라 좌표를 비교합니다. Compact에서 JubjubPoint는 opaque이고, 두 점 사이의 ==는 JavaScript 참조 동등으로 컴파일되므로, 새로 계산한 점에 대해서는 결코 성립하지 않습니다.

The challenge as a pure circuit​

오프체인 서명자는 circuit이 계산하는 것과 정확히 같은 challenge를 계산해야 합니다. 해시를 TypeScript에서 다시 구현하는 대신, 모듈은 이를 pure circuit으로 export합니다. ledger 접근도 witness도 없으므로, 컴파일러가 이에 대한 TypeScript 버전도 함께 생성합니다.

contract/schnorr.compact
// 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는 언어 버전을 고정하고 표준 라이브러리와 Schnorr 모듈을 import합니다. prefix Schnorr_는 모듈이 export하는 모든 이름에 네임스페이스를 붙이므로, schnorrVerify는 Schnorr_schnorrVerify가 됩니다:

contract/zk-loan.compact
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 };

다음은 데이터 타입입니다:

contract/zk-loan.compact
// 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>;
}
  • LoanStatus와 LoanApplication은 export됩니다. export가 이들을 공개로 만드는 것은 아닙니다. TypeScript 바인딩을 생성해 DApp이 이들을 읽고 만들 수 있게 할 뿐입니다. 어차피 이들은 공개인데, ledger의 loans가 이들을 저장하고 모든 ledger 상태는 공개이기 때문입니다.
  • Applicant은 export되지 않으므로 TypeScript 바인딩이 생성되지 않습니다. 이 타입이 private으로 남는 이유는 export되지 않아서가 아니라, 오직 witness와 circuit 내부 데이터로만 쓰이고 ledger에는 절대 기록되지 않기 때문입니다.

세 개의 key 타입은 각각 Bytes<32>에 대한 new type입니다:

contract/zk-loan.compact
// 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;는 T와 표현은 같지만 명목적인 타입을 만듭니다. 셋은 같은 32바이트 레이아웃을 공유하지만, 컴파일러는 circuit이 AdminPublicKey나 그냥 Bytes<32>를 기대하는 자리에 UserPublicKey를 넣는 것을 허용하지 않습니다. 어디서나 그냥 Bytes<32>를 써도 컴파일은 되지만, 그러면 caller가 user key를 admin key로 바꿔 쓸 수 있게 됩니다. 이 컨트랙트의 보안은 바로 그 혼동을 막는 데 달려 있습니다.

new type은 wrapper를 더하지 않습니다. 생성된 TypeScript에서 UserPublicKey는 일반 Uint8Array입니다. Compact에서는 value as UserPublicKey로 타입에 들어가고 pk as Bytes<32>로 나옵니다.

Ledger state​

contract/zk-loan.compact
// 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>;
  • loans는 중첩 map입니다. 바깥 키는 차입자가 도출한 public key, 안쪽 키는 대출 ID이므로, 차입자마다 여러 건의 대출을 가질 수 있습니다.
  • providers는 provider ID를 provider의 Jubjub public key에 매핑합니다. attestation signature는 이 key들에 대해 검증됩니다.
  • contractAdmin은 도출된 admin public key, 즉 admin secret의 해시를 담습니다. ledger는 secret 자체를 결코 보지 못합니다.
  • blacklist는 지갑 주소가 아니라 도출된 user public key를 담습니다.
  • onGoingPinMigration은 진행 중인 PIN 변경을 추적합니다. Changing the PIN을 참고하세요.

The constructor and witnesses​

배포자가 admin이 됩니다:

contract/zk-loan.compact
// 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()));
}

두 개의 witness가 private 데이터를 공급합니다. getAttestedScoringWitness는 신용 프로필, 그에 대한 provider의 서명, 그리고 어느 provider가 서명했는지를 반환합니다. getUserSecret은 컨트랙트의 모든 신원 뒤에 있는 32바이트 secret을 반환합니다:

contract/zk-loan.compact
// 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​

두 개의 pure circuit이 secret을 컨트랙트의 두 가지 신원으로 변환합니다:

contract/zk-loan.compact
// 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)은 차입자의 신원입니다. PIN에 묶여 있으므로 새 PIN을 쓰면 차입자에게 새 key가 생깁니다. 다만 연결 불가능한 새 출발은 아닙니다. changePin이 같은 트랜잭션에서 이전 key와 새 key를 함께 지목하므로, 체인을 지켜보는 누구든 둘을 연결할 수 있습니다.
  • deriveAdminPublicKey(sk)는 admin 신원입니다. PIN이 없으므로 admin 역할은 admin의 PIN 변경을 거쳐도 유지됩니다.

각 튜플 앞의 문자열은 도메인 분리자입니다. 같은 secret에서 나온 두 해시가 서로 충돌하거나 연관되지 않게 합니다. 바이트 수는 정확히 세세요. "zkloan:user:pk:v1"은 17바이트, "zkloan:admin:pk:v1"은 18바이트입니다. persistentHash는 튜플 전체를 해시하며, 여기에는 UserSecretKey 값이 그대로 포함됩니다.

두 circuit 모두 export되므로 오프체인 코드가 같은 key를 도출할 수 있습니다. 테스트는 차입자의 대출을 조회하려고 pureCircuits.deriveUserPublicKey를, contractAdmin을 확인하려고 pureCircuits.deriveAdminPublicKey를 호출합니다.

Requesting a loan​

requestLoan은 차입자가 호출하는 진입점입니다:

contract/zk-loan.compact
// 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);
}
  1. caller의 secret과 PIN에서 caller의 신원을 도출합니다. witness secret이 컨트랙트가 신뢰하는 유일한 caller 신원입니다.
  2. 도출된 key를 disclose()합니다. 이는 loans map의 public key이며, 컴파일러는 witness에서 도출된 값이 ledger 읽기나 쓰기에 도달하기 전에 명시적 disclosure를 요구합니다.
  3. 차입자가 blacklist에 없고 PIN 변경 중이 아닌지 확인합니다.
  4. 신원을 userPubKeyHash로 해시하고 비공개 평가를 실행합니다. provider가 이 해시를 프로필과 함께 서명했으므로, 다른 사람에게 발급된 서명은 실패합니다.
  5. 결과, 즉 등급 금액과 status만 disclose하고 대출을 기록합니다.
important

disclose()는 그 자체로 아무것도 공개하지 않습니다. witness에서 도출된 값이 circuit을 떠나도 된다고 표시하는 컴파일 타임 주석입니다. 값은 ledger 읽기나 쓰기가 그것을 사용할 때 공개됩니다. export된 circuit의 반환값에도 disclose()가 필요하지만, 그 반환값이 공개되는 것은 아닙니다. 트랜잭션은 호출의 입력과 출력에 대한 무작위화된 commitment만 담습니다. 신용 점수, 소득, 재직 기간은 결코 disclose되지 않고 ledger에도 도달하지 않으므로 private으로 유지됩니다.

The private evaluation​

evaluateApplicant은 export되지 않으므로 requestLoan의 proof 일부로만 실행될 수 있습니다:

contract/zk-loan.compact
// 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];
}
}

witness에서 프로필, 서명, provider ID를 가져오고, provider가 등록되어 있는지 확인한 뒤, 서명된 메시지를 원소 단위로 다시 만듭니다. Schnorr_schnorrVerify는 서명이 그 provider에 대해 그 메시지 그대로 유효하지 않으면 트랜잭션 전체를 실패시킵니다. 그제야 프로필이 신뢰되어 평가됩니다:

등급신용 점수월 소득재직 기간최대 금액
1700 이상2,000 이상24개월 이상10,000
2600 이상1,500 이상무관7,000
3580 이상무관무관3,000
Rejected580 미만무관무관0

Recording the loan​

contract/zk-loan.compact
// 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));
}

결과는 세 가지입니다:

  • Approved: 차입자가 자기 등급의 최대 금액을 넘지 않게 요청했고, 요청한 만큼 받습니다.
  • Proposed: 차입자가 더 많이 요청했습니다. 대출은 그 등급의 최대 금액을 기록하고 차입자의 답을 기다립니다.
  • Rejected: 프로필이 어느 등급에도 들지 못했습니다. 금액은 0입니다.

Responding to a proposal​

respondToLoan은 Proposed 대출을 처리합니다:

contract/zk-loan.compact
// 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));
}

별도의 소유권 검사는 없습니다. 대출은 caller의 secret과 PIN에서 도출한 신원으로 조회되므로, 소유자만 찾을 수 있습니다.

Admin circuits​

모든 admin circuit은 동일한 가드로 시작합니다:

contract/zk-loan.compact
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));
}

proof 안에서 contractAdmin == deriveAdminPublicKey(getUserSecret())는 caller가 contractAdmin에 저장된 해시의 원본 secret을 알도록 강제합니다. 누구나 ledger에서 contractAdmin을 읽을 수 있지만, secret이 없으면 그 값으로 해시되는 witness를 만들어 낼 수 없습니다.

blacklistUser는 지갑 주소가 아니라 도출된 UserPublicKey를 받습니다. admin은 이를 loans map의 키에서 얻는데, 차입자가 대출을 갖게 되면 거기에 나타납니다.

Why not ownPublicKey()?

이 컨트랙트의 예전 버전은 ownPublicKey() == admin을 검사했습니다. 그 검사는 우회할 수 있습니다. prover가 ownPublicKey()를 circuit에 제공하는데, 프로토콜은 이를 트랜잭션에 서명한 지갑과 대조하지 않으므로 caller가 그 자리에 아무 값이나 넣을 수 있습니다. 여기에 의존하는 모든 보안 판단도 마찬가지입니다. 컨트랙트가 shielded 토큰을 지갑으로 보내는 경우가 아니라면 ownPublicKey()를 쓰지 말고, caller 신원은 witness secret에서 도출하세요.

rotateAdmin은 역할을 새 admin에게 넘깁니다:

contract/zk-loan.compact
// 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);
}

새 admin은 자신의 secret을 생성하고, deriveAdminPublicKey를 오프체인에서 계산한 뒤, 그 결과인 public key만 공유합니다. secret은 오가지 않습니다.

Changing the PIN​

새 PIN은 새 신원을 도출하지만 차입자의 대출은 이전 신원에 기록되어 있으므로, changePin이 이들을 옮겨야 합니다. circuit은 가변 개수의 대출을 반복 처리할 수 없습니다. 모든 반복 횟수는 컴파일 타임에 알려져 있어야 합니다. 그래서 changePin은 호출마다 고정된 5건 묶음을 migration하고, onGoingPinMigration에 커서를 유지합니다:

contract/zk-loan.compact
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>;

첫 호출은 커서를 0에서 시작하고 차입자의 새 항목을 만듭니다. 그다음 각 호출은 최대 다섯 건의 대출을 옮깁니다:

contract/zk-loan.compact
// 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);
}
}
}
}
}

대출 12건을 가진 차입자의 경우:

  • 호출 1은 대출 1–5를 옮기고 커서를 5로 기록합니다.
  • 호출 2는 대출 6–10을 옮깁니다.
  • 호출 3은 대출 11과 12를 옮기고, 슬롯 13이 비어 있음을 확인한 뒤, 커서를 비우고 이제 빈 이전 신원을 제거합니다.

커서가 존재하는 동안 requestLoan은 이전 신원을 거부하므로, migration 뒤에 새 대출이 끼어들 수 없습니다.

Re-exporting the challenge​

컨트랙트는 모듈의 challenge 해시를 재export하여 생성된 TypeScript pureCircuits에 나타나게 하고, attestation API의 서명자가 거기서 이를 호출합니다:

contract/zk-loan.compact
// 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는 두 witness와 더불어 모듈의 getSchnorrReduction을 구현합니다. private state는 서명된 프로필과 user secret을 담습니다:

contract/witnesses.ts
export type ZkLoanPrivateState = {
creditScore: bigint;
monthlyIncome: bigint;
monthsAsCustomer: bigint;
attestationSignature: SchnorrSignature;
attestationProviderId: bigint;
userSecretKey: Uint8Array;
};

SchnorrSignature는 생성된 Schnorr_SchnorrSignature를 그대로 반영합니다. JubjubPoint는 { x, y } 좌표로 TypeScript에 넘어옵니다. 이 필드들은 어느 것도 ledger에 도달하지 않습니다.

헬퍼 하나가 secret과 비어 있는 미서명 프로필로 private state를 만듭니다. 이것만으로도 배포하고 admin circuit을 호출하기에 충분합니다. requestLoan은 서명된 프로필도 필요로 하며, 테스트는 이를 attestation API에서 얻습니다.

contract/witnesses.ts
// 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,
});

각 witness는 WitnessContext를 받고, 다음 private state와 circuit이 요청한 값으로 이루어진 튜플을 반환합니다:

contract/witnesses.ts
// 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];
},
};
  • getAttestedScoringWitness는 프로필, 서명, provider ID를 반환합니다. circuit은 이들을 신뢰하기 전에 서명을 검증합니다.
  • getSchnorrReduction은 challenge 해시를 2^248로 나눕니다. 정직한 몫은 항상 0과 115 사이이므로 circuit의 범위 검사를 통과합니다.
  • getUserSecret은 secret 자체, 즉 Uint8Array를 반환합니다. UserSecretKey가 Bytes<32>에 대한 new type이기 때문입니다. 길이 검사 덕분에 잘못된 형식의 private state는 불투명한 circuit 오류가 아니라 proof를 만드는 도중에 분명하게 실패합니다.

객체에는 Witnesses<ZkLoanPrivateState> 주석을 답니다. 추론에 맡기면 private state 타입 매개변수가 never로 무너지고, MidnightJS의 deploy·call 함수가 더 이상 타입 검사를 통과하지 못합니다.

contract/index.ts는 컴파일된 컨트랙트 CompiledZkLoanContract에 이 witness들을 붙여 export하며, 테스트와 attestation API가 이를 import합니다.

Compile​

examples/zk-loan에서:

yarn compile

이 명령은 compact compile contract/zk-loan.compact contract/managed/zk-loan을 실행합니다. 컴파일러는 컨트랙트 옆의 schnorr.compact를 찾아, circuit마다 proving key를 생성합니다:

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

circuit 여덟 개는 export된 impure circuit 하나당 하나씩입니다. pure circuit인 deriveUserPublicKey, deriveAdminPublicKey, schnorrChallenge는 proving key가 필요 없고, evaluateApplicant과 createLoan은 requestLoan 안으로 컴파일됩니다. changePin은 다섯 단계 루프가 circuit 안으로 펼쳐지므로 가장 큽니다.

컨트랙트에는 네트워크가 필요 없는 빠른 시뮬레이터 스위트도 있습니다. 위조 attestation 회귀 테스트를 포함해 모든 circuit을 메모리에서 실행합니다:

yarn test:sim

Next​

attestation API는 이 컨트랙트가 신뢰하는 provider입니다. 동일한 challenge 해시로 신용 데이터에 서명하므로, 그 서명이 proof 안에서 검증됩니다.