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

ZK Loan smart contract

이 튜토리얼에서는 Midnight Network 위에서 영지식(ZK) 대출 애플리케이션을 처음부터 구축합니다. 이 앱은 ZK proof로 사용자의 신용 데이터(신용 점수, 소득, 재직 기간)를 비공개로 평가하고, 대출 결과만 온체인에 기록합니다. 민감한 금융 데이터는 사용자의 기기를 벗어나지 않습니다.

기존 대출은 민감한 금융 데이터를 대출 기관, 중개인, 심사자에게 넘겨야 합니다. 이렇게 넘어간 데이터는 저장되고 공유되며 결국 유출됩니다. 2024년 한 해에만 금융 데이터 유출로 수억 건의 기록이 노출되었습니다. ZK proof는 이 구조를 뒤집습니다. 데이터를 보여 주는 대신, 데이터에 관한 명제를 증명합니다. "내 신용 점수는 700점을 넘고, 소득은 월 $2,000을 초과한다"라고 증명하면, verifier는 그 외에는 아무것도 알지 못합니다. Midnight Network는 이를 실용적으로 만들어 줍니다. Midnight은 데이터 보호를 목적으로 설계된 블록체인으로, 스마트 컨트랙트가 private 입력을 처리하고 그 결과만 온체인에 기록할 수 있습니다.

이 튜토리얼은 세 부분으로 나뉩니다:

  1. 스마트 컨트랙트: Compact(Midnight의 ZK 언어)로 작성합니다. 대출 로직, 자격 등급, 온체인 상태를 정의합니다.
  2. Attestation API: 신용 데이터를 Schnorr signature로 서명하는 서버로, 스마트 컨트랙트가 데이터의 출처가 신뢰할 수 있는 곳인지 검증할 수 있게 합니다.
  3. CLI: 스마트 컨트랙트를 배포하고, attestation provider를 등록하고, 대출을 요청하고, 시스템과 상호작용하는 명령줄 도구입니다. 모두 Midnight의 로컬 네트워크를 가리킵니다.

Project setup

이 섹션에서는 코드를 작성하기 전에 필요한 도구와 디렉터리 구조를 다룹니다.

Install prerequisites

Node.js v22 이상이 설치되어 있는지 확인하세요:

node --version

# Should print v22.x.x or higher
Node 20 will crash mid-sync

이 DApp은 @midnight-ntwrk/wallet-sdk barrel(shielded wallet을 재export합니다)을 사용하는데, 이 패키지는 shielded wallet 상태에서 Map.prototype.values().map(...)을 호출합니다. 여기에는 노드 22에 추가된 Iterator helpers가 필요합니다. 노드 20에서는 지갑이 시작되는 듯 보이다가 첫 sync 업데이트에서 state.pendingOutputs.values.map is not a function 오류로 죽습니다. nvm을 사용한다면 저장소 루트에 22(또는 원하는 22 이상 릴리스)가 담긴 .nvmrc 파일을 두어 nvm use가 자동으로 인식하게 하세요.

Midnight Compact 컴파일러를 설치하세요. 설치 가이드의 안내에 따라 시스템에 Compact toolchain을 설치합니다.

사용 가능한지 확인하세요:

compact compile --version

compact update를 실행해 최신 Compact toolchain을 설치하세요. 워크스페이스 package.json@midnight-ntwrk/compact-runtime: ^0.16.0을 선언하며, 이는 최신 compact compile이 생성하는 버전과 일치합니다.

Midnight JS 4.1.x protocol ACL

Midnight JS 4.1.x부터는 직접 작성한 코드에서 protocol 패키지(ledger, compact-runtime, compact-js, onchain-runtime, platform-js)를 더 이상 직접 import하지 않습니다. 대신 버전에 구애받지 않는 @midnight-ntwrk/midnight-js-protocol ACL 패키지의 subpath import로 가져옵니다. 예를 들면 @midnight-ntwrk/midnight-js-protocol/compact-runtime입니다. @midnight-ntwrk/compact-runtime은 여전히 직접 의존성으로 선언되어 있는데, 컴파일러가 생성한 컨트랙트 코드가 이를 직접 import하기 때문입니다. 다만 직접 작성하는 import는 모두 protocol subpath를 가리켜야 합니다. 이렇게 하면 코드가 특정 ledger 메이저 버전에 묶이지 않습니다.

또한 트랜잭션의 ZK proof를 로컬에서 생성하는 Midnight proof server를 실행하려면 Docker가 필요합니다. 아직 설치하지 않았다면 docker.com/get-docker에서 설치하고, Docker 데몬이 실행 중인지 확인하세요.

Create the project structure

루트 프로젝트 디렉터리를 만들고 monorepo 설정을 초기화합니다:

mkdir zkloan-credit-scorer
cd zkloan-credit-scorer

아래 코드 스니펫을 터미널에 그대로 붙여 넣어 루트 package.json을 초기화하세요. 이 프로젝트는 세 개의 워크스페이스를 가진 monorepo입니다:

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-runtime": "^0.16.0",
"@midnight-ntwrk/midnight-js-contracts": "4.1.1",
"@midnight-ntwrk/midnight-js-http-client-proof-provider": "4.1.1",
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.1.1",
"@midnight-ntwrk/midnight-js-level-private-state-provider": "4.1.1",
"@midnight-ntwrk/midnight-js-network-id": "4.1.1",
"@midnight-ntwrk/midnight-js-node-zk-config-provider": "4.1.1",
"@midnight-ntwrk/midnight-js-protocol": "4.1.1",
"@midnight-ntwrk/midnight-js-types": "4.1.1",
"@midnight-ntwrk/midnight-js-utils": "4.1.1",
"@midnight-ntwrk/wallet-sdk": "1.2.0",
"@midnight-ntwrk/wallet-sdk-address-format": "3.1.2",
"@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.1.0",
"@midnight-ntwrk/midnight-js-network-id": "4.1.1"
},
"resolutions": {
"@midnight-ntwrk/ledger-v8": "8.1.0",
"@midnight-ntwrk/midnight-js-network-id": "4.1.1"
}
}
EOF
4.0.x에서 달라진 점
  • @midnight-ntwrk/compact-js@midnight-ntwrk/ledger-v8 직접 의존성이 사라졌습니다. 둘 다 새로 추가된 @midnight-ntwrk/midnight-js-protocol을 통해 가져옵니다.
  • 개별 wallet-sdk 하위 패키지 5개(-facade, -hd, -shielded, -dust-wallet, -unshielded-wallet)가 @midnight-ntwrk/wallet-sdk barrel 하나로 통합되었습니다. barrel은 정확한 버전(1.2.0)으로 고정하세요. npm의 latest dist-tag는 아직 1.1.0을 가리키고 있어서, caret 범위를 쓰면 조용히 구버전으로 해석됩니다.
  • 모든 @midnight-ntwrk/midnight-js-* 패키지가 4.1.1로 올라가고, overrides/resolutions로 고정한 ledger는 8.1.0으로 이동합니다.
  • @midnight-ntwrk/compact-runtime은 직접 의존성으로 남습니다. 컴파일러가 생성한 컨트랙트 코드가 이를 직접 import하기 때문입니다.

다음으로, 아래 명령으로 .gitignore를 생성합니다:

cat > .gitignore << 'EOF'
node_modules/
**/dist/
.vite/
*.tsbuildinfo
logs
*.log
midnight-level-db
coverage
**/reports
.npm
.eslintcache
.env
**/.DS_Store
.vscode/
managed/
EOF

세 개의 워크스페이스 디렉터리를 생성합니다:

mkdir -p contract/src
mkdir -p zkloan-credit-scorer-cli/src
mkdir -p zkloan-credit-scorer-attestation-api/src

Write the Schnorr signature module

메인 스마트 컨트랙트를 작성하기 전에, Schnorr signature를 검증하는 모듈이 필요합니다. 스마트 컨트랙트는 이 모듈로 신용 데이터가 신뢰할 수 있는 attestation provider의 서명을 거쳤는지 검증합니다.

이 서명이 중요한 이유는 근본적인 문제 때문입니다. witness의 데이터는 그 자체로는 신뢰할 수 없습니다. witness는 사용자의 기기에서 실행되므로, 원하는 신용 점수를 무엇이든 넣을 수 있습니다. 신용 점수 750점에 월 소득 $5,000이라고 주장하는 사용자가 있다면, 거짓말을 막을 방법이 없습니다.

attestation 방식이 존재하는 이유가 바로 이것입니다. 신뢰할 수 있는 provider(은행, 신용평가기관, 점수 산정 서비스)가 사용자의 실제 데이터에 암호학적 서명을 합니다. 그러면 스마트 컨트랙트가 ZK circuit 안에서 그 서명을 검증합니다. 데이터가 변조되었다면 서명 검사가 실패하고 트랜잭션이 되돌려집니다.

이를 위해 스마트 컨트랙트는 Midnight의 고유 내부 곡선인 Jubjub 타원 곡선 위의 Schnorr signature를 사용합니다.

info

아래 Schnorr 검증 모듈은 임시 폴리필입니다. Midnight 팀은 jubjubSchnorrVerifyCompact Standard Library에 직접 넣고 있습니다. 이 기능이 출시되면 이 모듈 전체가 내장 함수 호출 하나로 대체됩니다. 지금은 직접 구현해 보는 것이 ZK circuit 안에서 서명 검증이 어떻게 작동하는지 이해하는 데 좋은 연습이 됩니다.

이제 contract/src/ 안에 schnorr.compact라는 파일을 만들고 아래 코드 스니펫을 추가하세요:

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;
}
}

위 Schnorr 코드에서:

SchnorrSignature는 두 개의 필드를 가진 struct입니다:

  • announcement: Jubjub 타원 곡선 위의 한 점 (Schnorr 서명에서 "R")

  • response: 스칼라 field 원소 (Schnorr 서명에서 "s")

schnorrVerify는 검증 circuit입니다. 다음 일을 합니다:

  • announcement 좌표, public key 좌표, 메시지를 해시해 challenge(cFull)를 만듭니다

  • challenge를 248비트로 잘라냅니다 (Jubjub 곡선 차수가 약 252비트이고, transientHash는 약 255비트인 BLS12-381 스칼라 field의 값을 출력하기 때문입니다)

  • Schnorr 등식을 검증합니다: G * response == announcement + publicKey * challenge

JubjubPoint equality

Compact 언어 버전 0.22부터 두 JubjubPoint 값은 ==로 비교할 수 없습니다. 컴파일러는 struct 동등 비교를 JS 참조 동등으로 처리하는데, 새로 생성된 점에 대해서는 항상 false이므로 assertion이 조용히 성립 불가능해집니다. 위 예시처럼 jubjubPointX() / jubjubPointY()xy 좌표를 명시적으로 비교하세요.

이 잘라내기에는 witness(getSchnorrReduction)를 사용합니다. TypeScript 코드가 2^248로 나눈 몫과 나머지를 제공하고, circuit은 q * 2^248 + r == cFull을 검증합니다. 이는 ZK 시스템에서 흔한 패턴으로, 비용이 큰 계산은 prover가 오프체인에서 처리하게 하고 그 결과만 온체인에서 저렴하게 검증합니다.

schnorrChallengepure circuit입니다(부수 효과 없음, ledger 접근 없음). schnorrVerify가 사용하는 것과 동일한 challenge 해시를 계산합니다. attestation API가 서명할 때 동일한 해시를 오프체인에서 계산할 수 있도록 export됩니다.

이로써 준비가 끝났으니, 다음 단계에서 실제 대출 스마트 컨트랙트를 작성합니다.

Write the loan smart contract

contract/src/ 폴더 안에 zkloan-credit-scorer.compact를 생성합니다.

The header, types, ledger state, and constructor

이 첫 번째 블록은 언어 버전, import, 데이터 타입, ledger 상태, constructor(witness secret에서 초기 admin public key를 도출), 그리고 그 secret을 사용자별 신원과 admin 신원으로 변환하는 두 개의 pure circuit을 선언합니다:

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>;
}

// 모든 브라우저/CLI 인스턴스는 private 상태에 단일 32바이트 secret을 보관합니다.
// 이 contract의 모든 신원 — admin 역할과 사용자별 대출 신원 모두 —
// 은 그 하나의 secret에서 도메인 분리 해시를 통해 도출됩니다.
// `ownPublicKey()`는 절대 사용하지 않습니다. 이 함수는 트랜잭션 서명자와
// 암호학적으로 묶이지 않은, prover가 주장한 값을 반환하므로 여기에 의존하는
// 모든 assertion은 우회가 가능합니다.
export new type UserSecretKey = Bytes<32>;
export new type UserPublicKey = Bytes<32>;
export new type AdminPublicKey = 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;

// 사용자별 신원, PIN으로 교체 가능. PIN을 바꾸면 새로 도출된 public key가
// 나오므로 이전 신원과의 연결성이 끊어집니다.
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 신원. PIN 바인딩 없음 — admin 역할은 admin의 PIN 교체와 무관하게
// 일정하게 유지됩니다. 배포자의 `deriveAdminPublicKey(secret)`은 생성 시점에
// `contractAdmin`으로 고정됩니다.
export pure circuit deriveAdminPublicKey(sk: UserSecretKey): AdminPublicKey {
return persistentHash<[Bytes<18>, UserSecretKey]>([
"zkloan:admin:pk:v1",
sk
]) as AdminPublicKey;
}

모든 Compact smart contract는 pragma(언어 버전)와 import로 시작합니다. 표준 라이브러리와 Schnorr 모듈은 이름이 충돌하지 않도록 prefix를 붙여 import합니다.

다음으로 데이터 타입을 정의합니다. 두 가지를 눈여겨보세요:

  • LoanApplicationLoanStatusexport됩니다. export 키워드가 이들을 공개로 만드는 것은 아닙니다. 컴파일된 아티팩트에 TypeScript 바인딩을 생성해 DApp이 이 타입을 읽고 만들 수 있게 할 뿐입니다. 이 타입이 온체인에 보이는 이유는 loans ledger map에 저장되는 값 타입이자 필드 타입이기 때문입니다. 모든 ledger 상태는 공개이므로 누구나 대출의 status와 승인 금액을 읽을 수 있습니다.
  • Applicantexport되지 않으므로 TypeScript 바인딩이 생성되지 않습니다. 이 타입이 private으로 유지되는 이유는 export가 없어서가 아니라, 오직 witness와 circuit 내부 데이터로만 쓰이고 ledger 상태에는 절대 기록되지 않기 때문입니다. 신용 점수, 소득, 재직 기간은 온체인에 전혀 나타나지 않습니다.

UserSecretKey, UserPublicKey, AdminPublicKey는 각각 new type으로 선언합니다. Bytes<32>에 대한 명목적(nominal) alias입니다. 형식은 new type X = T;이고, T와 표현은 같지만 별개인 타입을 만듭니다. 셋 다 같은 32바이트 레이아웃을 공유하지만 컴파일러는 서로 다른 타입으로 취급합니다. circuit이 AdminPublicKey를 기대하는 자리에 UserPublicKey(또는 raw Bytes<32>)를 넘기면 거부하고, 그 반대도 마찬가지입니다. 어디서나 그냥 Bytes<32>를 써도 컴파일은 되지만, 그러면 user key와 admin key를 서로 바꿔 쓸 수 있게 됩니다. 이 컨트랙트의 보안은 바로 그런 혼동을 막는 데 달려 있습니다.

new type은 기존에 쓰던 단일 필드 wrapper struct(struct UserPublicKey { bytes: Bytes<32>; })보다 가볍습니다. 객체 wrapper를 더하지 않으므로 값은 Compact에서는 그대로 Bytes<32>, 생성된 TypeScript에서는 { bytes: ... } 객체가 아닌 일반 Uint8Array입니다. UserPublicKey { ... }UserPublicKey(...) 같은 생성자는 없습니다. 타입으로 들어갈 때는 value as UserPublicKey, 원래 바이트로 나올 때는 pk as Bytes<32>를 씁니다. exported circuit나 ledger 시그니처에 등장하는 new type에는 export를 붙여서 컴파일러가 TypeScript 바인딩을 생성하게 하세요.

ledger 선언은 블록체인에 무엇이 올라가는지 정의합니다:

  • loans중첩 map입니다. 바깥 키는 사용자가 도출한 public key 바이트(Bytes<32>), 안쪽 키는 대출 ID(Uint<16>), 값은 LoanApplication입니다. 이 구조로 각 사용자가 여러 건의 대출을 가질 수 있습니다.
  • providers는 provider ID를 Jubjub 곡선 점(provider의 public key)에 매핑합니다. 스마트 컨트랙트는 등록된 이 key들에 대해 attestation signature를 검증합니다.
  • contractAdmin은 배포자의 도출된 admin public key를 저장합니다: persistentHash("zkloan:admin:pk:v1" || userSecret). 배포자는 32바이트 secret을 private 상태에 보관하고, ledger에는 해시만 올라갑니다. 모든 admin circuit은 ZK proof 안에서 caller에게 이 저장 값의 preimage를 안다는 것을 증명하도록 강제합니다.
  • blacklist는 지갑 주소가 아니라 도출된 UserPublicKey 값을 저장합니다. 특정 PIN에서 사용자의 UserPublicKeypersistentHash("zkloan:user:pk:v1" || hash(pin) || userSecret)입니다. 악의적인 caller는 다른 wallet pubkey를 주장해 blacklist 검사를 우회할 수 없습니다. 이 컨트랙트에서 caller의 신원은 자신의 witness secret을 해시한 결과 그 자체이기 때문입니다.
  • onGoingPinMigration은 사용자가 PIN을 바꿀 때 진행 상황을 추적합니다(자세한 내용은 신원 섹션에서 다룹니다).
  • deriveUserPublicKey(sk, pin)은 사용자별 신원 도출이고, deriveAdminPublicKey(sk)는 admin 도출입니다. 둘 다 동일한 32바이트 witness secret을 사용합니다. 다만 도메인 분리 문자열이 서로 다르므로 두 도출 결과는 서로 연관되지 않습니다.

이 해시들이 만들어지는 방식에서 두 가지를 짚고 갑니다. 첫째, persistentHashVector만이 아니라 어떤 타입이든 해시할 수 있습니다. 여기서는 이종 튜플([Bytes<17>, Bytes<32>, UserSecretKey])을 받아 UserSecretKey 값을 .bytes 필드 추출 없이 그대로 해시합니다. 둘째, 도메인 분리 문자열 리터럴은 실제 바이트 길이 그대로 튜플에 들어갑니다. pad()는 쓰지 않습니다. 바이트 수는 정확히 세야 합니다. "zkloan:user:pk:v1"17바이트(Bytes<17>)이고 "zkloan:admin:pk:v1"18바이트(Bytes<18>)입니다. admin 부분이 들어가서 한 바이트 더 길어지므로, 다른 곳의 길이를 복사하지 말고 항상 직접 세세요. 각 circuit은 ... as UserPublicKey / ... as AdminPublicKey로 끝나는데, 이는 결과 Bytes<32>에 시그니처가 약속한 명목 타입을 부여하는 것입니다.

note

이 circuit들이 export를 유지하는 이유는 오프체인 코드가 이를 통해 key를 도출하기 때문입니다. CLI/UI는 사용자별 key를 pureCircuits.deriveUserPublicKey로, admin key를 pureCircuits.deriveAdminPublicKey로 호출합니다. circuit의 export는 필수가 아니라 선택입니다. key를 온체인에서만 도출하는 컨트랙트라면 도출 circuit은 export하지 않아도 됩니다. 또 한 가지, pad() 제거는 해시 preimage를 바꿉니다(32바이트 zero-padded 문자열 대신 raw 17/18바이트 문자열). 따라서 같은 secret이라도 이전 struct 기반 코드와는 다른 public key가 도출됩니다. 모든 consumer가 이 하나의 컴파일된 circuit을 통해 도출하므로 서로 간에는 일치하지만, 옛 코드로 이미 배포한 인스턴스는 다른 신원을 도출하므로 새 배포로 취급해야 합니다.

witness는 두 개입니다. getAttestedScoringWitness는 신청자의 신용 프로필과 Schnorr 서명이 된 attestation을 반환합니다. getUserSecret은 컨트랙트의 모든 신원 — admin 역할과 사용자별 PIN 바인딩 신원 — 을 만들어 내는 32바이트 secret을 반환합니다. TypeScript 구현은 다음 섹션에서 다룹니다.

note

아직 파일을 닫지 마세요. 나머지 블록을 이 파일에 이어서 추가합니다.

Core loan circuits

이제 스마트 컨트랙트의 핵심인, 대출 요청을 처리하는 circuit을 추가합니다. 아래 코드 스니펫을 사용하세요:

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 as Bytes<32>),
"PIN migration is in progress for this user");
const userPubKeyHash = transientHash<Bytes<32>>(disclosedRequesterPubKey as Bytes<32>);
const [topTierAmount, status] = evaluateApplicant(userPubKeyHash);
const disclosedTopTierAmount = disclose(topTierAmount);
const disclosedStatus = disclose(status);
createLoan(disclosedRequesterPubKey as Bytes<32>, 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 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");

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은 사용자가 호출하는 메인 진입점입니다. 흐름은 다음과 같습니다:

  1. deriveUserPublicKey(getUserSecret(), secretPin)으로 caller의 사용자별 신원을 도출합니다. witness secret이 이 컨트랙트에서 유일하게 권위 있는 caller 신원입니다.
  2. 도출된 pubkey를 disclose()해서, 컴파일러가 그 값이 ledger 읽기(blacklist.member, onGoingPinMigration.member)로 흘러가도록 허용하게 합니다. 이 값은 witness에서 도출되었으므로 명시적인 disclosure가 필요합니다.
  3. 사용자가 blacklist에 없고 PIN 변경 중이 아닌지 확인합니다.
  4. evaluateApplicant()를 호출합니다. 비공개 신용 점수 산정이 일어나는 곳입니다. disclose된 pubkey의 transientHash가 attestation 메시지가 서명되는 대상인 userPubKeyHash가 되어, 오프체인 attestation을 circuit 안의 이 특정 신원에 묶습니다.
  5. 결과(금액과 status)만 disclose()하고, 대출 기록을 ledger에 씁니다.
important

evaluateApplicant은 전부 ZK circuit 안에서 실행됩니다. witness에서 사용자의 신용 데이터를 읽고, attestation signature를 검증하며, 자격 등급을 반환합니다. disclose()는 그 자체로 무언가를 공개하지 않습니다. witness에서 도출된 값을 private 영역 밖으로 내보내도 안전하다고 표시하는 컴파일 타임 주석일 뿐입니다. 값은 공개 경계를 넘을 때, 즉 ledger 필드에 기록되거나 export된 circuit에서 반환될 때 비로소 공개됩니다. 신용 점수, 소득, 재직 기간은 결코 disclose되지 않고 그런 경계를 넘지도 않으므로 private으로 유지됩니다. disclose되는 것은 자격 결과(금액과 status)뿐이고, 이를 ledger에 기록하는 동작이 실제로 그 값을 공개합니다.

또한 evaluateApplicant은 내부 circuit입니다(export되지 않으며 외부에서 호출할 수 없습니다). 다음 일을 합니다:

  • witness에서 사용자의 신용 프로필, Schnorr signature, provider ID를 가져옵니다
  • provider가 온체인에 등록되어 있는지 검증합니다
  • Schnorr signature를 검증합니다. 이로써 데이터가 신뢰할 수 있는 provider에게서 왔으며 사용자가 위조하지 않았음을 증명합니다
  • 신용 프로필을 세 등급에 맞춰 평가합니다:
TierCredit ScoreMonthly IncomeTenureMax Amount
1>= 700>= $2,000>= 24개월$10,000
2>= 600>= $1,500무관$7,000
3>= 580무관무관$3,000
Rejected< 580무관무관$0

createLoan은 status 로직을 처리합니다. 가능한 결과는 세 가지입니다:

  • Approved: 사용자가 자신의 최대 자격 금액 이하를 요청한 경우입니다. 요청한 금액을 그대로 받습니다.
  • Proposed: 사용자가 자격 한도보다 많은 금액을 요청한 경우입니다. 스마트 컨트랙트가 최대 자격 금액을 제안하고, 사용자가 수락하거나 거절하기를 기다립니다.
  • Rejected: 신용 점수가 너무 낮은 경우입니다. 금액 = 0.

respondToLoan은 사용자가 Proposed 상태의 대출을 수락하거나 거절할 수 있게 합니다. 수락하면 status가 Approved로 바뀝니다. 거절하면 NotAccepted로 바뀌고 승인 금액이 0으로 초기화됩니다.

Admin circuits

접근이 제한된 이 연산들은 witness 도출 keypair 패턴을 사용합니다. 모든 admin circuit은 동일한 가드로 시작합니다:

assert(contractAdmin == deriveAdminPublicKey(getUserSecret()), "Only admin can ...");

ZK proof 안에서 이 가드는 caller가 contractAdmin에 저장된 공개 값의 32바이트 preimage를 안다는 것을 강제합니다. ledger 값만으로는 공격자에게 쓸모가 없습니다. 증명 입력에 복사해 넣을 수는 있지만, secret을 모르면 해시가 일치하는 witness를 제출할 수 없습니다.

Why not ownPublicKey()?

이 컨트랙트의 예전 버전은 assert(ownPublicKey() == admin, "...")을 사용했습니다. 그 패턴은 우회가 가능합니다. ownPublicKey()는 prover가 circuit 컨텍스트에 제공하는 값이며, 프로토콜은 이 값을 트랜잭션에 서명한 지갑과 대조하지 않습니다. caller는 그 자리에 어떤 32바이트 값이든 넣을 수 있고 assertion은 그대로 성립합니다. 같은 우회가 ownPublicKey()에 의존하는 다른 모든 검사에도 적용됩니다. blacklist 멤버십, PIN 바인딩 신원 도출 등 ownPublicKey()의 결과가 보안 관련 판단으로 흘러가는 모든 경우입니다.

엄격한 규칙은 이렇습니다: 컨트랙트가 shielded 토큰을 대상 지갑으로 보내는 경우가 아니라면, ownPublicKey()를 호출하지 마세요. caller 신원에는 witness에서 도출한 secret을 사용하세요.

다섯 개의 admin circuit은 다음과 같습니다:

  • blacklistUser / removeBlacklistUser: 도출된 UserPublicKey를 blacklist에 추가하거나 제거합니다. admin은 대상의 UserPublicKey를 온체인 loans map 키(대상이 상호작용할 때마다 거기에 나타납니다)에서 얻거나, 대상에게서 별도 경로로 받습니다. admin은 지갑 주소로 blacklist 처리할 수 없습니다. 컨트랙트는 ownPublicKey()를 신뢰하지 않습니다.

  • registerProvider / removeProvider: attestation provider의 public key를 추가하거나 제거합니다.

  • rotateAdmin: admin 역할을 새로 도출된 public key로 넘깁니다. 새 admin은 자신의 secret을 로컬에서 생성하고, deriveAdminPublicKey를 오프체인에서 계산한 뒤, 그 결과인 32바이트 값만 현재 admin에게 공유합니다. private key는 네트워크를 거치지 않습니다.

아래 코드 스니펫을 추가하세요:

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

이 마지막 블록에는 두 개의 circuit이 있습니다. PIN migration과 Schnorr challenge 재내보내기입니다.

사용자별 신원은 위 헤더 블록에서 정의한 deriveUserPublicKey(getUserSecret(), pin)입니다. witness secret과 PIN을 함께 쓰면 결정적인 UserPublicKey가 나오고, 그 바이트가 온체인에 나타납니다. secret과 PIN을 둘 다 알지 못하면 지갑 주소를 대출 기록과 연결할 수 없으므로, 사용자에게 추가적인 프라이버시 보호 계층이 생깁니다.

changePin은 가장 복잡한 circuit입니다. 사용자가 PIN을 바꾸면 도출 결과로 새 온체인 신원이 나옵니다. 그런데 기존 대출은 이전 신원에 묶여 있습니다. 모든 대출을 이전 UserPublicKey에서 새 UserPublicKey로 옮겨야 합니다.

함정이 하나 있습니다. ZK circuit은 가변 길이 데이터를 반복 처리할 수 없습니다. 사용자가 대출 12건을 가지고 있어도 for i in 0..loans.size()라고 쓸 수 없습니다. 반복 횟수는 컴파일 타임에 알려져 있어야 합니다. 해결책은 일괄 migration입니다:

  • 트랜잭션당 정확히 5건의 대출을 처리합니다(컴파일 타임에 for (const i of 0..5)로 고정).
  • onGoingPinMigration에 진행 상황을 추적합니다. migration이 어디까지 진행되었는지 저장합니다.
  • 사용자는 모든 대출이 옮겨질 때까지 changePin을 반복 호출합니다.
  • 완료되면 migration 상태가 정리됩니다.

대출 12건을 가진 사용자의 경우:

  • 호출 1: 대출 1–5를 옮기고 진행 상황을 기록

  • 호출 2: 대출 6–10을 옮기고 진행 상황을 기록

  • 호출 3: 대출 11–12를 옮기고, 슬롯 13–15가 비어 있음을 확인한 뒤 정리

migration이 진행되는 동안 해당 사용자의 requestLoan은 차단됩니다(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");

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는 Schnorr challenge 해시 함수를 pure circuit으로 재내보냅니다(부수 효과 없음, ledger 접근 없음). attestation API가 서명할 때 동일한 해시를 오프체인에서 계산할 수 있도록, 이 함수는 생성된 TypeScript에서 사용 가능해야 합니다.

이제 스마트 컨트랙트 파일이 완성되었습니다. 다음으로 넘어가기 전에, 무엇이 private이고 무엇이 public인지 정리하면 다음과 같습니다:

DataVisibilityWhy
32바이트 user secretPrivate (witness 전용)권위 있는 caller 신원. 사용자의 기기를 벗어나지 않음
신용 점수, 소득, 재직 기간Private (witness 전용)사용자의 기기를 벗어나지 않음
Secret PINPrivate (circuit 입력)user secret과 함께 해시되어 사용자별 신원이 되며, 저장되지 않음
Attestation signaturePrivate (ZK proof 입력)circuit 안에서 검증됨
도출된 UserPublicKey (loan map 키)Public (ledger)user secret과 PIN을 둘 다 알지 못하면 연결 불가
대출 status와 금액Public (ledger)온체인 결과
contractAdmin, blacklistPublic (ledger)스마트 컨트랙트 거버넌스

Create the witness function (private data provider)

witness는 proving 시점에 ZK circuit에 private 데이터를 제공하는 TypeScript 코드입니다. 사용자의 기기에서 실행되며, 온체인에서는 절대 실행되지 않습니다.

contract/src/witnesses.ts를 생성합니다:

import { Ledger } from "./managed/zkloan-credit-scorer/contract/index.js";
import { WitnessContext } from "@midnight-ntwrk/midnight-js-protocol/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바이트 — caller의 진짜 신원
};

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,
Uint8Array,
] => {
if (!privateState.userSecretKey || privateState.userSecretKey.length !== 32) {
throw new Error("getUserSecret: userSecretKey is missing or wrong length");
}
return [privateState, privateState.userSecretKey];
},
};

위 코드에서 각 witness 함수는 현재 privateState를 담은 WitnessContext를 받고, 다음을 반환해야 합니다:

  • (필요 시 갱신된) private 상태
  • circuit이 요청한 값
  • getAttestedScoringWitness: private 상태에서 신용 프로필, attestation signature, provider ID를 추출합니다.

  • getSchnorrReduction: challenge 해시를 2^248로 나눈 몫과 나머지를 계산합니다(Jubjub 스칼라 field 잘라내기).

  • getUserSecret: 이 컨트랙트의 모든 caller 신원을 만들어 내는 32바이트 secret을 반환합니다. UserSecretKey가 이제 Bytes<32>에 대한 new type이므로, 생성된 TypeScript 타입은 일반 Uint8Array입니다. witness는 { bytes: ... } wrapper 없이 secret 자체를 반환합니다. 컨트랙트는 이 값을 두 개의 pure circuit에 넣습니다. 사용자별 PIN 바인딩 신원을 위한 deriveUserPublicKey(secret, pin)과 admin 역할을 위한 deriveAdminPublicKey(secret)입니다. 해시했을 때 contractAdmin에 저장된 값이 나오는 secret은 배포한 admin의 것뿐입니다. 다른 사람의 getUserSecret은 admin 도출 결과가 일치하지 않는 값을 반환하므로 admin assertion을 통과하지 못합니다. 도메인 분리 문자열("zkloan:admin:pk:v1""zkloan:user:pk:v1")이 다르므로 같은 secret에서 나온 두 도출 결과는 서로 연관되지 않습니다. 런타임 길이 검사로 잘못된 형식의 witness는 증명 시점에 거부됩니다.

Create the smart contract TypeScript exports

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

이 파일은 (Compact 컴파일러가) 생성한 스마트 컨트랙트 코드와 witness 구현을 함께 재내보냅니다.

이제 터미널에서 아래 명령으로 스마트 컨트랙트의 package.json을 생성합니다:

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

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": "bundler",
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strict": true,
"isolatedModules": true,
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
EOF
moduleResolution: bundler가 필요합니다

Midnight JS 4.1.x는 protocol 패키지의 exports subpath 맵(@midnight-ntwrk/midnight-js-protocol/compact-runtime, /ledger, /compact-js 등)을 사용합니다. 레거시 moduleResolution: node 리졸버는 이 맵을 읽지 못합니다. protocol subpath에서 import하는 모든 워크스페이스에 bundler, node16, nodenext 중 하나를 사용하세요.

contract/tsconfig.build.json을 생성합니다:

cat > contract/tsconfig.build.json << 'EOF'
{
"extends": "./tsconfig.json",
"exclude": ["src/test/**/*.ts"],
"compilerOptions": {}
}
EOF

Compile the smart contract

프로젝트 루트에서 모든 의존성을 설치합니다:

npm install

Compact smart contract를 컴파일합니다:

cd contract
npm run compact

이 명령은 다음을 담은 src/managed/zkloan-credit-scorer/ 디렉터리를 생성합니다:

  • contract/ — 생성된 스마트 컨트랙트의 TypeScript 구현

  • keys/ — 각 circuit의 proving 키와 verifying 키

  • zkir/ — ZK 중간 표현 파일

  • compiler/ — 컴파일러 메타데이터

이제 TypeScript를 빌드합니다:

npm run build
cd ..

이 시점에서 스마트 컨트랙트 패키지가 컴파일되어, CLI와 attestation API가 사용할 준비가 됩니다.

What you built in Part 1

이 파트에서는 ZK 대출 애플리케이션의 기반을 다루었습니다:

  • Schnorr signature module: ZK circuit 안에서 암호학적 서명을 검증해, 신용 데이터가 신뢰할 수 있는 출처에서 왔음을 보장하는 Compact 모듈입니다.
  • Loan smart contract: 대출 요청 로직, 등급별 자격 평가, admin 제어, PIN 기반 신원 도출, 일괄 PIN migration을 갖춘 완전한 Compact smart contract입니다.
  • Witness implementation: proving 시점에 private 신용 데이터를 온체인에 노출하지 않고 ZK circuit에 공급하는 TypeScript 코드입니다.
  • TypeScript exports and compilation: 패키지 설정, 컴파일러 출력, 그리고 생성된 ZK circuit·키·바인딩입니다.

스마트 컨트랙트가 컴파일되어 준비를 마쳤습니다. 신용 데이터는 블록체인에 닿지 않으며, 대출 결과만 기록됩니다.

Next steps

다음 파트에서는 스마트 컨트랙트가 동작하도록 만드는 오프체인 인프라를 구축합니다:

  • Attestation API: 신용 데이터를 Schnorr signature로 서명하는 REST 서버로, 스마트 컨트랙트가 검증 대상으로 삼는 신뢰할 수 있는 데이터 provider 역할을 합니다.
  • Attestation flow: 사용자, attestation API, Midnight Network가 처음부터 끝까지 어떻게 상호작용하는지 살펴봅니다.
  • Docker setup for the proof server: 트랜잭션의 ZK proof를 생성하는 로컬 proof server를 설정합니다.