For the complete documentation index, see llms.txt
Attestation API
컨트랙트는 등록된 provider가 서명한 신용 프로필만 신뢰합니다.
이 파트에서는 그 provider, 즉 examples/zk-loan/attestation-api/의 attestation API를 살펴봅니다.
Jubjub 곡선 위의 Schnorr signature로 신용 데이터에 서명하는 작은 REST 서버입니다. 실제 환경에서는
은행이나 신용평가 기관의 서비스가 되겠지만, 여기서는 그것을 대신합니다.
Prerequisites
- 이 튜토리얼의 ZK Loan contract 파트.
The endpoints
이 API에는 세 개의 endpoint가 있습니다:
POST /attest는 신용 데이터와 차입자 신원의 해시를 받아, 그에 대한 Schnorr signature를 반환합니다.GET /provider-info는 provider의 ID와 public key를 반환합니다. admin은 이를registerProvider로 온체인에 등록합니다.GET /health는 서버 상태를 반환합니다.
Wire types
attestation-api/types.ts는 요청과 응답의 형태를 정의합니다:
// Wire types for the attestation API. JSON has no bigint, so every field-sized
// value travels as a decimal string; clients convert with BigInt(...).
export interface AttestationRequest {
creditScore: number;
monthlyIncome: number;
monthsAsCustomer: number;
// transientHash(deriveUserPublicKey(secret, pin)) as a decimal string. The
// borrower computes it locally, so the API never learns the secret or PIN.
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를 문자열로 받습니다. 이 해시는 field 원소라 JSON 숫자로 담기엔 너무 크므로, 십진 문자열로 전달됩니다.AttestationResponse는 같은 이유로 서명의 announcement 점과 response 스칼라를 문자열로 반환합니다.message는 서명된 내용을 그대로 돌려줍니다.
차입자는 자신의 secret과 PIN에서 userPubKeyHash를 로컬로 계산하므로, provider는 둘 중 어느 것도
알지 못합니다.
Signing
attestation-api/signing.ts는 contract/schnorr.compact의 오프체인 쪽 절반입니다. 두 개의 상수로
시작합니다. Jubjub 부분군의 위수와, challenge 잘라내기에 쓰는 2^248입니다.
import * as crypto from 'node:crypto';
import { ecMulGenerator, type JubjubPoint } from '@midnight-ntwrk/midnight-js-protocol/compact-runtime';
import { pureCircuits, type Schnorr_SchnorrSignature } from '../contract/index.js';
// Order of the Jubjub prime-order subgroup. Scalars passed to ecMulGenerator
// must be below this or it throws "out of bounds for prime field".
export const JUBJUB_ORDER = 6554484396890773809930967563523245729705921265872317281365359162392183254199n;
// The circuit truncates the BLS12-381 challenge hash to 248 bits so it is a
// valid Jubjub scalar; the signer must truncate the same way.
export const TWO_248 = 452312848583266388373324160190187140051835877600158453279131187530910662656n;
const reduce = (x: bigint): bigint => ((x % JUBJUB_ORDER) + JUBJUB_ORDER) % JUBJUB_ORDER;
서명자는 contract/index.ts에서 pureCircuits를 import합니다. 이는 컴파일러가 컨트랙트의 pure
circuit에서 생성한 TypeScript이며, schnorrChallenge도 여기에 포함됩니다.
키 생성은 Jubjub 위수보다 작은 무작위 스칼라를 고릅니다:
// A uniformly-ish random scalar. 256 random bits reduced mod the ~2^252 order
// has negligible bias for a demo; a production signer would use rejection
// sampling and HSM-held keys.
function randomScalar(): bigint {
return BigInt('0x' + crypto.randomBytes(32).toString('hex')) % JUBJUB_ORDER;
}
export function generateKeyPair(): { sk: bigint; pk: JubjubPoint } {
const sk = randomScalar();
return { sk, pk: ecMulGenerator(sk) };
}
export function getPublicKey(sk: bigint): JubjubPoint {
return ecMulGenerator(reduce(sk));
}
그다음은 서명 자체입니다:
// Sign a 4-element message. The nonce k is fresh per signature — reusing a
// nonce across two messages leaks the secret key.
export function sign(sk: bigint, msg: bigint[]): Schnorr_SchnorrSignature {
sk = reduce(sk);
const pk = ecMulGenerator(sk);
const k = randomScalar();
const R = ecMulGenerator(k);
// pureCircuits.schnorrChallenge returns the full transientHash output; the
// circuit reduces it mod 2^248 before the EC ops, so do the same here.
const c = pureCircuits.schnorrChallenge(R.x, R.y, pk.x, pk.y, msg) % TWO_248;
// s must be reduced mod the Jubjub order (not the BLS12-381 field), because
// the circuit feeds it to ecMulGenerator.
const s = reduce(k + c * sk);
return { announcement: R, response: s };
}
단계별로 보면:
reduce로 비밀 키를 Jubjub 스칼라 field로 줄입니다.ecMulGenerator는 Jubjub 위수보다 작은 스칼라만 받고, 그렇지 않으면 "out of bounds for prime field" 오류를 던집니다. 그래서 보통 위수보다 큰 임의의 32바이트 값에서 로드한 키도 정상 동작합니다.- 새로운 무작위 nonce
k를 고르고, announcementR = k*G를 계산합니다. - circuit이 쓰는 것과 같은 해시인
pureCircuits.schnorrChallenge로 challenge를 계산하고, 248비트로 잘라냅니다. - response
s = k + c*sk를 계산하되 Jubjub 위수를 법으로 줄입니다. circuit이s를ecMulGenerator에 넘기기 때문입니다.
그러면 circuit은 s*G == R + c*pk를 검사합니다. 3단계가 핵심입니다. 서명자가 컨트랙트가 직접
컴파일한 schnorrChallenge를 호출하므로, circuit과 똑같은 해시를 계산합니다. 다른 해시를 썼다면 모든
서명이 검증에 실패했을 것입니다.
nonce를 재사용하지 마세요. 같은 k로 서로 다른 메시지에 서명한 두 서명은 비밀 키를 드러냅니다.
sign은 매번 새 k를 뽑습니다.
signCreditData는 evaluateApplicant이 circuit 안에서 다시 만드는 것과 정확히 같은 배치로 메시지를
구성합니다:
// The exact message layout `evaluateApplicant` rebuilds in-circuit:
// [creditScore, monthlyIncome, monthsAsCustomer, userPubKeyHash]. The last
// element binds the attestation to one borrower identity, so it cannot be
// replayed by anyone else.
export function signCreditData(
sk: bigint,
creditScore: number | bigint,
monthlyIncome: number | bigint,
monthsAsCustomer: number | bigint,
userPubKeyHash: bigint,
): Schnorr_SchnorrSignature {
return sign(sk, [BigInt(creditScore), BigInt(monthlyIncome), BigInt(monthsAsCustomer), userPubKeyHash]);
}
마지막 원소 userPubKeyHash는 서명을 하나의 차입자 신원에 묶습니다. 다른 차입자는 자신의 신원이 다른
값으로 해시되므로 이 서명을 재사용할 수 없습니다.
The REST server
attestation-api/server.ts는 endpoint를 서명자에 연결합니다. Restify를
사용하고 모든 origin을 허용하므로, 브라우저 DApp이 직접 호출할 수 있습니다:
export function createServer(providerSk: bigint, providerId: number): restify.Server {
const server = restify.createServer({ name: 'zkloan-attestation-api' });
server.use(restify.plugins.bodyParser());
// Permissive CORS so a browser DApp can call the API directly.
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);
POST /attest는 네 필드가 모두 있는지 확인하고, provider의 비밀 키로 서명한 뒤, 모든 bigint을 십진
문자열로 담아 서명과 서명된 메시지를 반환합니다:
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: signature.announcement.x.toString(),
y: signature.announcement.y.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) {
res.send(500, { error: err instanceof Error ? err.message : String(err) });
}
return next();
});
GET /provider-info는 admin이 이 provider를 온체인에 등록하는 데 필요한 값을 반환하고,
GET /health는 표준 health check입니다:
server.get('/provider-info', (_req: restify.Request, res: restify.Response, next: restify.Next) => {
const response: ProviderInfoResponse = {
providerId,
publicKey: { x: providerPk.x.toString(), y: providerPk.y.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;
}
이 서버는 받은 신용 데이터라면 무엇이든 서명합니다. 실제 provider라면 요청 본문을 믿는 대신 호출자를 인증하고 수치를 자체 기록에서 조회할 것입니다. 컨트랙트는 이 provider가 데이터를 보증했다는 것만 증명하므로, provider가 신뢰의 기준점입니다.
The entry point
attestation-api/index.ts는 provider 키를 로드하거나 생성하고 서버를 시작합니다:
setNetworkId(process.env['NETWORK_ID'] || 'undeployed');
const PORT = parseInt(process.env['PORT'] || '4000', 10);
const PROVIDER_ID = parseInt(process.env['PROVIDER_ID'] || '1', 10);
let providerSk: bigint;
const secretHex = process.env['PROVIDER_SECRET_KEY'];
if (secretHex) {
// Reduce so any 32-byte hex value is a valid Jubjub scalar.
providerSk = BigInt('0x' + secretHex) % JUBJUB_ORDER;
console.log('Loaded provider secret key from environment');
} else {
providerSk = generateKeyPair().sk;
console.log('Generated ephemeral provider key pair');
}
const pk = getPublicKey(providerSk);
console.log(`Provider ID: ${PROVIDER_ID}`);
console.log('Provider public key:');
console.log(` x: ${pk.x}`);
console.log(` y: ${pk.y}`);
console.log(`Register this provider on-chain with: registerProvider(${PROVIDER_ID}, {x: ${pk.x}n, y: ${pk.y}n})`);
createServer(providerSk, PROVIDER_ID).listen(PORT, () => {
console.log(`Attestation API listening on port ${PORT}`);
});
환경 변수 네 개를 읽습니다:
PORT: 리스닝 포트. 기본값 4000.PROVIDER_ID: 키가 등록되는 온체인 ID. 기본값 1.PROVIDER_SECRET_KEY: 16진수 Jubjub 비밀 키. 이 값이 없으면 서버는 시작할 때마다 새 키를 생성하고, 그 키를 다시 온체인에 등록해야 합니다. 이 값이 있으면 public key가 재시작을 거쳐도 동일하게 유지됩니다. 무작위 32바이트 값은 대부분 위수보다 크므로, 이 값은 먼저 Jubjub 위수를 법으로 줄여집니다.NETWORK_ID: 기본값은 로컬 네트워크인undeployed입니다.preprod같은 다른 네트워크를 대상으로 하려면 설정하세요.
Run it
examples/zk-loan에서:
yarn attestation:start
서버는 자신의 키와 바로 쓸 수 있는 registerProvider 호출을 출력합니다:
Generated ephemeral provider key pair
Provider ID: 1
Provider public key:
x: 34011401554120741160298202835178627774966657519840293748374731768831074333877
y: 20561868703134644164447864025937537271049689058944105758742005191640850207216
Register this provider on-chain with: registerProvider(1, {x: 34011401554120741160298202835178627774966657519840293748374731768831074333877n, y: 20561868703134644164447864025937537271049689058944105758742005191640850207216n})
Attestation API listening on port 4000
키는 새로 생성되므로 사람마다 다릅니다. 두 번째 터미널에서 endpoint를 확인하세요:
curl http://localhost:4000/health
curl http://localhost:4000/provider-info
{"status":"ok","providerId":1}
{"providerId":1,"publicKey":{"x":"34011401554120741160298202835178627774966657519840293748374731768831074333877","y":"20561868703134644164447864025937537271049689058944105758742005191640850207216"}}
그다음 서명을 요청합니다. 여기서는 userPubKeyHash로 어떤 십진 문자열이든 쓸 수 있습니다. 실제로는
차입자가 자기 신원의 해시를 보냅니다.
curl -X POST http://localhost:4000/attest \
-H 'Content-Type: application/json' \
-d '{"creditScore": 720, "monthlyIncome": 2500, "monthsAsCustomer": 24, "userPubKeyHash": "12345"}'
응답은 서명을 담고 서명된 메시지를 그대로 돌려줍니다:
{
"signature": {
"announcement": {
"x": "31540621476077479166376266535540036184142785888380769319902548457379701327240",
"y": "44823578977567077845389280972004925387492839733725374129431623294795868131990"
},
"response": "6414907552153898933550119210015492650698244154133242502297393619952011729853"
},
"message": {
"creditScore": "720",
"monthlyIncome": "2500",
"monthsAsCustomer": "24",
"userPubKeyHash": "12345"
}
}
Ctrl+C로 서버를 멈춥니다. 다음 파트에서는 서버를 띄워 둘 필요가 없습니다. 테스트 스위트가 같은 서버를 자체적으로 빈 포트에 in-process로 띄웁니다.
이 API에는 HTTP 핸들러와 서명자를 위한 자체 단위 테스트가 있습니다. 네트워크가 필요 없습니다:
yarn test src/test/attestation-api
The attestation flow
세 당사자에 걸쳐 attestation이 어떻게 동작하는지 보면 다음과 같습니다:
┌──────────┐ ┌────────────────┐ ┌───────────────┐
│ Borrower │ │ Attestation │ │ Midnight │
│ (DApp) │ │ 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 │
│<─────────────────────────────────────────│
- provider 등록. admin이
registerProvider를 호출해 API의 public key를 컨트랙트의providersmap에 저장합니다. 설정 단계 중 체인에 닿는 것은 이 단계뿐입니다. - attestation 요청. 차입자는 private state의 secret과 PIN에서
deriveUserPublicKeypure circuit으로 신원을 도출한 뒤 이를 해시합니다. 그 해시와 신용 데이터를 API로 보냅니다. - 서명과 반환. API는 네 값을 하나의 Schnorr signature로 서명합니다. 신원 해시를 포함하면 attestation이 이 차입자에 묶입니다.
- 대출 요청 제출. 차입자는 서명을 private state에 저장하고
requestLoan을 호출합니다. 서명은 witness의 일부로 proof server에 전달됩니다. 체인에 도달하는 트랜잭션에는 결코 나타나지 않습니다. - ZK에서 검증. circuit 안에서
evaluateApplicant이 witness에서 서명을 가져오고, ledger에서 provider의 public key를 조회한 뒤schnorrVerify를 실행합니다. 데이터가 변조됐거나, 다른 provider가 서명했거나, attestation이 다른 사람의 것이면 assert가 실패하고 트랜잭션이 제출되지 않습니다. - 결과 기록. 대출의 status와 승인 금액만 ledger에 기록됩니다. 신용 점수, 소득, 재직 기간, PIN, 서명은 비공개로 유지됩니다.
각 당사자가 알게 되는 것:
- 차입자는 거짓말할 수 없다. 컨트랙트가 circuit 안에서 provider의 서명을 검증하므로, 지어낸 신용 데이터는 실패합니다.
- 체인은 결과만 안다. 신용 데이터와 서명은 proof의 private 입력입니다. 관찰자는 도출된 key 아래의 대출 status와 금액만 볼 뿐, 그 외에는 아무것도 보지 못합니다.
- provider는 자신이 서명하는 것을 보며, 그것을 연결할 수 있다. provider는 이미 그 수치를 알고
있습니다. 바로 그 출처이기 때문입니다. 차입자의 secret이나 PIN은 결코 알지 못합니다. 하지만
userPubKeyHash는 차입자가 도출한 key의 단순 해시이고, 그 key는loans에 공개되어 있습니다. 따라서 ledger를 지켜보는 provider는 자신의 attestation을 차입자의 대출과 맞춰 볼 수 있습니다.
Next
ZK Loan tests는 전체 흐름을 로컬 devnet에서 실행합니다. 컨트랙트를 배포하고, 이 API의 키를 등록하고, 대출을 받고, 모든 부정 시도가 거부되는지 확인합니다.