For the complete documentation index, see llms.txt
Attestation API
아무도 호출할 수 없는 스마트 컨트랙트는 컴파일러 출력 디렉토리에 놓인 개념 증명일 뿐입니다.
Part 1에서는 Compact로 영지식(ZK) 대출 평가 스마트 컨트랙트를 구현했습니다. 신용 점수, 소득, 재직 기간은 비공개로 유지되고, 대출 결과만 온체인에 기록됩니다. 또한 사용자가 자신의 신용 데이터를 위조하지 못하게 막는 Schnorr signature 모듈과, prover에 private input을 공급하는 TypeScript witness도 다뤘습니다.
하지만 지금 이 스마트 컨트랙트에는 서명된 신용 데이터를 받거나 ZK proof를 생성할 방법이 없습니다. 이 파트에서는 스마트 컨트랙트를 실제로 동작하게 만드는 두 가지 오프체인 인프라를 구축합니다.
-
Attestation API: Jubjub 곡선 위에서 Schnorr signature로 신용 데이터에 서명하는 REST server입니다. 은행이나 신용평가기관을 대신하는 신뢰된 데이터 제공자 역할을 하며, 스마트 컨트랙트는 ZK circuit 안에서 이 서명을 검증합니다.
-
Proof server: Midnight의 증명 생성 서비스를 로컬에서 실행하는 Docker 컨테이너입니다. 스마트 컨트랙트와 상호작용하는 모든 트랜잭션은 ZK proof가 필요하며, 이 서비스가 그 증명을 생성합니다.
이 절에서는 attestation 흐름을 처음부터 끝까지 따라가며, 신용 데이터가 온체인에 전혀 노출되지 않으면서 attestation API에서 ZK proof로 어떻게 전달되는지 보여줍니다.
Prerequisites: Part 1을 완료했고, 컴파일된 스마트 컨트랙트 패키지가 contract/dist/ 디렉토리에 준비되어 있는지 확인하세요.
Build the attestation API
attestation API는 Schnorr signature로 신용 데이터에 서명하는 신뢰된 서비스입니다. 실제 운영 환경에서는 은행이나 신용평가기관의 API가 이 역할을 맡습니다. 이 튜토리얼에서는 REST server를 Restify로 구축합니다.
이 API에는 세 개의 endpoint가 있습니다.
-
POST /attest: 신용 데이터와 사용자 공개 키 해시를 받아 Schnorr signature를 반환합니다. -
GET /provider-info: 제공자의 ID와 공개 키를 반환합니다. CLI는 이 값을 사용해 제공자를 온체인에 등록합니다. -
GET /health: 서버 상태를 반환합니다.
Type definitions
먼저 요청과 응답의 형태를 정의합니다. zkloan-credit-scorer-attestation-api/src 폴더 안에 types.ts 파일을 만들고 다음 코드를 추가하세요.
export interface AttestationRequest {
creditScore: number;
monthlyIncome: number;
monthsAsCustomer: number;
userPubKeyHash: string;
}
export interface AttestationResponse {
signature: {
announcement: { x: string; y: string };
response: string;
};
message: {
creditScore: string;
monthlyIncome: string;
monthsAsCustomer: string;
userPubKeyHash: string;
};
}
export interface ProviderInfoResponse {
providerId: number;
publicKey: { x: string; y: string };
}
export interface HealthResponse {
status: string;
providerId: number;
}
이 타입들에서 몇 가지 짚어둘 점이 있습니다.
-
AttestationRequest는 숫자형 신용 데이터와 문자열로 변환된userPubKeyHash를 받습니다. 이 해시는 내부적으로bigint이지만, JSON은 임의 정밀도 정수를 지원하지 않으므로 문자열로 직렬화합니다. -
AttestationResponse도 같은 이유로 Schnorr signature 구성 요소(announcement 점과 scalar response)를 문자열로 반환합니다. -
message필드는 서명된 데이터를 그대로 돌려보내, 호출자가 서명을 검증할 수 있게 합니다.
Schnorr signing implementation
이 서명 모듈은 key pair를 생성하고, 온체인 스마트 컨트랙트가 검증할 수 있는 Schnorr signature를 만듭니다.
zkloan-credit-scorer-attestation-api/src 폴더 안에 signing.ts 파일을 만들고 다음 코드를 추가하세요.
import {
ecMulGenerator,
jubjubPointX,
jubjubPointY,
type JubjubPoint,
} from "@midnight-ntwrk/midnight-js-protocol/compact-runtime";
import { ZKLoanCreditScorer } from "zkloan-credit-scorer-contract";
const { pureCircuits } = ZKLoanCreditScorer;
type SchnorrSignature = {
announcement: JubjubPoint;
response: bigint;
};
import * as crypto from "crypto";
// Midnight이 사용하는 Jubjub 타원 곡선 부분군의 위수(order)입니다.
// 모든 scalar 연산(nonce 생성, response 계산)은 유효한 곡선 연산이
// 되도록 이 값으로 나눈 나머지를 취해야 합니다.
const JUBJUB_ORDER =
6554484396890773809930967563523245729705921265872317281365359162392183254199n;
// challenge 해시를 잘라내는 데 쓰는 2^248 값입니다. Jubjub 곡선 위수는
// 약 252비트지만, transientHash는 BLS12-381 scalar field(약 255비트)의
// 값을 출력합니다. 2^248로 나눈 나머지를 취하면 challenge가 곡선에
// 안전한 범위 안에 머뭅니다. 이 값을 그대로 사용하세요 — 변경할 수 없습니다.
const TWO_248 =
452312848583266388373324160190187140051835877600158453279131187530910662656n;
function randomScalar(): bigint {
const bytes = crypto.randomBytes(32);
let val = BigInt("0x" + bytes.toString("hex"));
return val % JUBJUB_ORDER;
}
export function generateKeyPair(): { sk: bigint; pk: JubjubPoint } {
const sk = randomScalar();
const pk = ecMulGenerator(sk);
return { sk, pk };
}
export function getPublicKey(sk: bigint): JubjubPoint {
return ecMulGenerator(sk);
}
export function sign(sk: bigint, msg: bigint[]): SchnorrSignature {
const pk = ecMulGenerator(sk);
const k = randomScalar();
const R = ecMulGenerator(k);
const cFull = pureCircuits.schnorrChallenge(
jubjubPointX(R),
jubjubPointY(R),
jubjubPointX(pk),
jubjubPointY(pk),
msg,
);
const c = cFull % TWO_248;
const s = (((k + c * sk) % JUBJUB_ORDER) + JUBJUB_ORDER) % JUBJUB_ORDER;
return { announcement: R, response: s };
}
export function signCreditData(
sk: bigint,
creditScore: number,
monthlyIncome: number,
monthsAsCustomer: number,
userPubKeyHash: bigint,
): SchnorrSignature {
const msg: bigint[] = [
BigInt(creditScore),
BigInt(monthlyIncome),
BigInt(monthsAsCustomer),
userPubKeyHash,
];
return sign(sk, msg);
}
How the signing works
서명에는 Jubjub 타원 곡선(Midnight의 내부 기본 곡선)을 사용합니다. Schnorr 서명 흐름은 단계별로 다음과 같습니다.
- 무작위 nonce
k를 생성합니다. - announcement
R = G * k를 계산합니다(G는 곡선 generator). pureCircuits.schnorrChallenge()로 challenge 해시를 계산합니다 — 이는 스마트 컨트랙트가 사용하는 것과 동일한 해시 함수이며, 서명이 온체인에서 검증되려면 반드시 같아야 합니다.- challenge를 248비트로 잘라냅니다:
c = cFull % 2^248. - response를 계산합니다:
s = (k + c * sk) mod JUBJUB_ORDER.
이렇게 만들어진 signature는 (R, s)입니다. 스마트 컨트랙트는 G * s == R + publicKey * c를 확인하여 검증합니다.
핵심은 3단계입니다. pureCircuits.schnorrChallenge() 함수는 Part 1에서 다룬 Compact smart contract의 pure circuit schnorrChallenge에서 생성됩니다. 오프체인 서명자와 온체인 검증자가 같은 해시 함수를 쓰기 때문에, 여기서 만든 서명이 ZK circuit 안에서 검증됩니다. 다른 해시를 쓰면 모든 서명이 검증에 실패합니다.
signCreditData 함수는 편의용 래퍼입니다. 네 가지 신용 데이터 필드(신용 점수, 월 소득, 고객 유지 개월 수, 사용자 공개 키 해시)를 받아 bigint으로 변환한 뒤 범용 sign 함수에 넘깁니다.
REST server
이 서버는 세 개의 endpoint를 노출하고 이를 서명 로직에 연결합니다.
zkloan-credit-scorer-attestation-api/src/server.ts를 만드세요.
import restify from 'restify';
import { signCreditData, getPublicKey } from './signing.js';
import type {
AttestationRequest,
AttestationResponse,
ProviderInfoResponse,
HealthResponse,
} from './types.js';
import {
jubjubPointX,
jubjubPointY,
type JubjubPoint,
} from '@midnight-ntwrk/midnight-js-protocol/compact-runtime';
export function createServer(
providerSk: bigint,
providerId: number,
): restify.Server {
const server = restify.createServer({ name: 'zkloan-attestation-api' });
server.use(restify.plugins.bodyParser());
server.pre(
(req: restify.Request, res: restify.Response, next: restify.Next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.send(204);
return next(false);
}
return next();
},
);
const providerPk: JubjubPoint = getPublicKey(providerSk);
server.post(
'/attest',
(req: restify.Request, res: restify.Response, next: restify.Next) => {
try {
const body = req.body as AttestationRequest;
if (
body.creditScore == null ||
body.monthlyIncome == null ||
body.monthsAsCustomer == null ||
body.userPubKeyHash == null
) {
res.send(400, {
error:
'Missing required fields: creditScore, monthlyIncome, monthsAsCustomer, userPubKeyHash',
});
return next();
}
const userPubKeyHash = BigInt(body.userPubKeyHash);
const signature = signCreditData(
providerSk,
body.creditScore,
body.monthlyIncome,
body.monthsAsCustomer,
userPubKeyHash,
);
const response: AttestationResponse = {
signature: {
announcement: {
x: jubjubPointX(signature.announcement).toString(),
y: jubjubPointY(signature.announcement).toString(),
},
response: signature.response.toString(),
},
message: {
creditScore: body.creditScore.toString(),
monthlyIncome: body.monthlyIncome.toString(),
monthsAsCustomer: body.monthsAsCustomer.toString(),
userPubKeyHash: userPubKeyHash.toString(),
},
};
res.send(200, response);
} catch (err: any) {
res.send(500, { error: err.message });
}
return next();
},
);
server.get(
'/provider-info',
(_req: restify.Request, res: restify.Response, next: restify.Next) => {
const response: ProviderInfoResponse = {
providerId,
publicKey: {
x: jubjubPointX(providerPk).toString(),
y: jubjubPointY(providerPk).toString(),
},
};
res.send(200, response);
return next();
},
);
server.get(
'/health',
(_req: restify.Request, res: restify.Response, next: restify.Next) => {
const response: HealthResponse = {
status: 'ok',
providerId,
};
res.send(200, response);
return next();
},
);
return server;
}
각 endpoint의 역할은 다음과 같습니다.
-
POST /attest는 핵심 endpoint입니다. 신용 데이터와 사용자 공개 키 해시를 받아 제공자의 secret key로 데이터에 서명하고 Schnorr signature를 반환합니다.userPubKeyHash는 서명된 메시지에 포함됩니다 — 이렇게 하면 attestation이 특정 사용자 신원에 묶이므로, 한 사용자가 다른 사용자의 attestation을 재사용할 수 없습니다. -
GET /provider-info는 제공자의 ID와 공개 키 좌표를 반환합니다. CLI는 이 endpoint로 온체인 제공자 등록에 필요한 값을 가져옵니다. -
GET /health는 표준 health check입니다.
Entry point
entry point는 키 관리를 처리하고 서버를 시작합니다.
zkloan-credit-scorer-attestation-api/src 폴더 안에 index.ts 파일을 만들고 다음 코드를 추가하세요.
import {
jubjubPointX,
jubjubPointY,
} from '@midnight-ntwrk/midnight-js-protocol/compact-runtime';
import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import { createServer } from './server.js';
import { generateKeyPair, getPublicKey } from './signing.js';
setNetworkId(process.env.NETWORK_ID || 'preprod');
const PORT = parseInt(process.env.PORT || '4000', 10);
const PROVIDER_ID = parseInt(process.env.PROVIDER_ID || '1', 10);
let providerSk: bigint;
if (process.env.PROVIDER_SECRET_KEY) {
providerSk = BigInt('0x' + process.env.PROVIDER_SECRET_KEY);
console.log('Loaded provider secret key from environment');
} else {
const keyPair = generateKeyPair();
providerSk = keyPair.sk;
console.log('Generated ephemeral provider key pair');
}
const pk = getPublicKey(providerSk);
const pkX = jubjubPointX(pk);
const pkY = jubjubPointY(pk);
console.log(`Provider ID: ${PROVIDER_ID}`);
console.log(`Provider public key:`);
console.log(` x: ${pkX}`);
console.log(` y: ${pkY}`);
console.log(
`Register this provider on-chain with: registerProvider(${PROVIDER_ID}, {x: ${pkX}n, y: ${pkY}n})`,
);
const server = createServer(providerSk, PROVIDER_ID);
server.listen(PORT, () => {
console.log(`Attestation API listening on port ${PORT}`);
});
entry point는 두 가지 모드를 지원합니다.
-
Ephemeral mode(기본값): 시작할 때마다 새 key pair를 생성합니다. 개발과 테스트에는 유용하지만, 서버를 재시작할 때마다 키가 바뀝니다. 재시작 후에는 제공자를 온체인에 다시 등록해야 합니다.
-
Persistent mode:
PROVIDER_SECRET_KEY환경 변수에 16진수로 인코딩된 secret key를 설정합니다. 서버가 시작할 때 이 키를 불러오므로, 재시작 후에도 공개 키가 동일하게 유지됩니다.
서버가 시작되면 제공자의 공개 키 좌표와 바로 쓸 수 있는 registerProvider 커맨드를 출력합니다. 이 값들을 복사해 두세요 — Part 3에서 CLI로 제공자를 등록할 때 필요합니다.
Package configuration
루트 zkloan-credit-scorer-attestation-api 폴더 안에 package.json 파일을 만들고 다음 코드를 추가하세요.
{
"name": "zkloan-credit-scorer-attestation-api",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts"
},
"dependencies": {
"zkloan-credit-scorer-contract": "0.1.0",
"@midnight-ntwrk/compact-runtime": "^0.16.0",
"@midnight-ntwrk/midnight-js-network-id": "4.1.1",
"@midnight-ntwrk/midnight-js-protocol": "4.1.1",
"restify": "^11.1.0"
},
"devDependencies": {
"@types/restify": "^8.5.12",
"tsx": "^4.19.0"
}
}
attestation API는 @midnight-ntwrk/midnight-js-protocol/compact-runtime에서 ecMulGenerator, jubjubPointX, jubjubPointY, JubjubPoint를 가져오므로, 여기서 midnight-js-protocol은 직접 의존성입니다. @midnight-ntwrk/compact-runtime도 그대로 두어야 합니다. 생성된 컨트랙트 코드(zkloan-credit-scorer-contract를 통해 간접적으로 import됩니다)가 런타임에 이를 필요로 하기 때문입니다.
다음으로 zkloan-credit-scorer-attestation-api/tsconfig.json을 다음 코드로 만드세요.
{
"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
},
"include": ["src/**/*.ts"]
}
moduleResolution은 레거시 node가 아니라 bundler입니다. 그래야 TypeScript가 @midnight-ntwrk/midnight-js-protocol이 /compact-runtime, /ledger, /compact-js를 배포할 때 사용하는 exports subpath 맵을 읽을 수 있습니다. node16과 nodenext도 동작합니다.
Understanding the attestation flow
API를 구축했으니, attestation이 세 컴포넌트에 걸쳐 처음부터 끝까지 어떻게 동작하는지 살펴봅니다.
┌──────────┐ ┌────────────────┐ ┌───────────────┐
│ User │ │ Attestation │ │ Midnight │
│ (CLI) │ │ API │ │ Network │
└────┬─────┘ └───────┬────────┘ └───────┬───────┘
│ │ │
│ 1. Admin registers provider PK on-chain │
│──────────────────────────────────────────>│
│ │ │
│ 2. POST /attest │ │
│ {creditScore, │ │
│ monthlyIncome, │ │
│ monthsAsCustomer│ │
│ userPubKeyHash} │ │
│──────────────────>│ │
│ │ │
│ 3. Returns signed│ │
│ Schnorr signature│ │
│<──────────────────│ │
│ │ │
│ 4. Submit loan request with signature │
│ (signature in private state, never │
│ visible on-chain) │
│──────────────────────────────────────────>│
│ │ │
│ │ 5. ZK circuit │
│ │ verifies signature │
│ │ against registered │
│ │ PK (all in zero- │
│ │ knowledge) │
│ │ │
│ 6. Only loan status + amount on ledger │
│<─────────────────────────────────────────│
위 다이어그램의 번호에 맞춰 각 단계를 짚어봅니다.
- 제공자 등록. 관리자가 온체인에서
registerProvider를 호출해, attestation API의 Jubjub 공개 키를 스마트 컨트랙트의providers맵에 저장합니다. 블록체인을 건드리는 유일한 설정 단계입니다. - attestation 요청. CLI는 사용자의 신용 데이터와 파생된 공개 키 해시를 attestation API로 보냅니다. CLI는 Part 1의
publicKeypure circuit으로 사용자 지갑 키와 비밀 PIN에서 공개 키 해시를 계산합니다. - 서명 후 반환. attestation API는 네 필드(신용 점수, 월 소득, 고객 유지 개월 수, 사용자 공개 키 해시)를 하나의 Schnorr signature로 서명합니다. 서명된 메시지에 공개 키 해시를 포함하면 attestation이 특정 사용자에 묶이므로, 다른 사용자가 이 서명을 재사용할 수 없습니다.
- 대출 요청 제출. CLI는 서명을 사용자의 private state에 저장하고
requestLoan을 호출합니다. 서명은 ZK witness의 일부로 proof server에 전달됩니다. 블록체인에 도달하는 트랜잭션 데이터에는 절대 나타나지 않습니다. - ZK에서 검증. circuit 안에서
evaluateApplicant는 witness에서 서명을 가져오고, ledger에서 제공자의 공개 키를 조회한 뒤schnorrVerify를 실행합니다. 데이터가 변조되었거나, 잘못된 제공자가 서명했거나, attestation이 다른 사용자의 것이면 검증에 실패합니다. 이 경우 assertion이 실패하고 트랜잭션은 되돌려집니다. - 결과 기록. 대출 상태(Approved, Proposed, Rejected)와 승인 금액만
disclose()를 통해 ledger에 기록됩니다. 신용 점수, 소득, 재직 기간, PIN, attestation 서명은 비공개로 유지됩니다.
이를 통해 양방향 프라이버시가 보장됩니다.
-
사용자는 거짓말할 수 없습니다: 스마트 컨트랙트가 circuit 안에서 attestation 제공자의 서명을 검증하므로, 위조된 신용 데이터는 검증에 실패합니다.
-
제공자는 결과를 볼 수 없습니다: ZK proof는 서명된 데이터를 private input으로 다루므로, attestation API는 온체인 활동을 들여다볼 수 없습니다.
Set up Docker for the proof server
Preprod 대신 Midnight Local Dev로 테스트한다면 이 단계는 건너뛰세요. 로컬 개발 환경에는 이미 6300 포트에 proof server가 포함되어 있습니다.
proof server는 스마트 컨트랙트와 상호작용하는 모든 트랜잭션의 ZK proof를 생성합니다. Preprod에서는 블록체인 노드와 indexer는 원격(Midnight Network 호스팅)이고, proof server는 Docker로 로컬에서 실행합니다.
zkloan-credit-scorer-cli/proof-server.yml을 만드세요.
services:
proof-server:
image: "midnightntwrk/proof-server:8.1.0"
ports:
- "6300:6300"
environment:
RUST_BACKTRACE: "full"
proof server는 최신 버전을 사용하세요. 사용 중인 SDK에 맞는 버전은 호환성 매트릭스에서 확인하세요.
proof server를 시작합니다.
cd zkloan-credit-scorer-cli
docker compose -f proof-server.yml up -d
실행 중인지 확인합니다.
docker compose -f proof-server.yml ps
6300 포트에서 proof-server 컨테이너가 실행 중인 것을 확인할 수 있습니다. 서버가 응답하는지 확인하려면 호스트에서 curl http://localhost:6300/version을 실행하세요. compose 파일에 curl 기반 healthcheck는 추가하지 마세요. proof-server 이미지에는 curl이 포함되어 있지 않아, 서버가 정상이어도 컨테이너 내부 healthcheck는 실패합니다.
proof server는 이 스택에서 연산 부담이 가장 큰 컴포넌트입니다. Part 3에서 CLI로 트랜잭션을 제출하면, proof server는 circuit 정의, public input(ledger 상태), private input(witness 데이터)을 받습니다. 그런 다음 ZK proof를 만들어 Midnight Network에 제출합니다. 이 증명은 private input을 드러내지 않으면서 연산이 올바르게 수행되었음을 증명합니다.
개발 단계에서는 Docker로 로컬에서 실행하는 것으로 충분합니다. 운영 환경에서는 더 많은 연산 자원을 갖춘 전용 인프라에서 proof server를 실행합니다.
Next steps
마지막 파트에서는 CLI를 구축하고 전체 흐름을 처음부터 끝까지 실행합니다.
- CLI: Midnight Preprod 네트워크에서 지갑 생성, 스마트 컨트랙트 배포, attestation 제공자 등록, 대출 요청, 온체인 상태 조회를 수행하는 대화형 커맨드 라인 도구입니다.
- End-to-end testing: tNIGHT로 지갑 생성 및 충전, 스마트 컨트랙트 배포, 제공자 등록, 자격 등급별 대출 요청, 그리고 대출 결과만 온체인에 나타나는지 검증합니다.