For the complete documentation index, see llms.txt
Part 1: The smart contract
이 섹션에서는 리더보드를 구동하는 Compact 스마트 컨트랙트를 작성합니다.
Initialize the project
프로젝트 디렉터리를 만든 다음, 워크스페이스 설정과 Midnight SDK 의존성을 담은 루트 package.json을 구성합니다.
mkdir midnight-leaderboard
cd midnight-leaderboard
touch package.json
이 설정은 npm 워크스페이스를 구성하고 모든 Midnight SDK 의존성을 포함합니다.
{
"name": "midnight-leaderboard",
"version": "0.1.0",
"private": true,
"type": "module",
"workspaces": ["contract", "api", "leaderboard-ui"],
"scripts": {
"compile": "cd contract && npm run compact",
"build": "cd contract && npm run build && cd ../api && npm run build"
},
"devDependencies": {
"@vitejs/plugin-react": "^5.1.4",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0"
},
"dependencies": {
"@midnight-ntwrk/compact-js": "2.5.1",
"@midnight-ntwrk/dapp-connector-api": "4.0.1",
"@midnight-ntwrk/midnight-js-contracts": "4.1.1",
"@midnight-ntwrk/midnight-js-fetch-zk-config-provider": "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-network-id": "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",
"fp-ts": "^2.16.11",
"graphql": "^16.11.0",
"effect": "^3.14.0",
"pino": "^10.3.1",
"rxjs": "^7.8.2",
"semver": "^7.7.4"
},
"overrides": {
"smoldot": "npm:@aspect-build/empty@0.0.0"
}
}
Compact 컨트랙트의 compile·build 스크립트를 정의하는 컨트랙트 디렉터리 구조와 package.json을 만듭니다.
mkdir -p contract/src
touch contract/package.json
compact 스크립트는 Compact 컴파일러를 실행합니다. build 스크립트는 TypeScript를 컴파일하고 managed 출력을 dist/로 복사합니다.
{
"name": "leaderboard-contract",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"scripts": {
"compact": "compact compile leaderboard.compact managed/leaderboard",
"build": "rm -rf dist && tsc --project tsconfig.build.json && cp -Rf ./managed ./dist/managed"
}
}
witness는 TextEncoder를 사용하므로 "DOM" lib 항목이 필요합니다. compact-js의 서브패스 export에는 "Bundler" 모듈 resolution이 필요합니다. "types": []는 다른 패키지에 속한 모노레포 루트의 타입 정의를 TypeScript가 자동으로 포함하지 않도록 막습니다.
touch contract/tsconfig.build.json
이 TypeScript 설정은 ES2022를 타깃으로 하며, 컴파일된 JavaScript와 함께 선언 파일(declaration)을 출력합니다.
{
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"lib": ["ESNext", "DOM"],
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
"strict": true,
"isolatedModules": true,
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": []
},
"include": ["src"]
}
Write the contract
컨트랙트 소스 파일을 만듭니다. 이 파일에 Compact 스마트 컨트랙트 전체가 들어갑니다.
touch contract/leaderboard.compact
이 컨트랙트는 자동 증가하는 counter를 키로 하는 Map에 저장되는 ScoreEntry struct를 정의합니다. witness 함수를 통해 프론트엔드가 필요할 때 비공개 데이터를 circuit에 전달할 수 있습니다.
pragma language_version 0.23;
import CompactStandardLibrary;
struct ScoreEntry {
score: Uint<64>,
displayName: Bytes<32>,
ownerHash: Bytes<32>
}
export ledger scores: Map<Uint<64>, ScoreEntry>;
export ledger nextId: Counter;
witness localSecretKey(): Bytes<32>;
witness getCustomName(): Bytes<32>;
scores는 indexer에서 읽을 수 있는 공개 ledger 필드입니다. nextId는 자동으로 증가하며 고유한 entry ID를 부여합니다. 두 개의 witness 선언은 TypeScript 호스트가 런타임에 localSecretKey와 getCustomName을 제공한다고 컴파일러에 알립니다. localSecretKey는 사용자의 private state에 저장된 무작위 secret을 반환합니다. getCustomName은 커스텀 표시 이름을 반환합니다.
ownerCommitment
이 헬퍼 circuit은 사용자의 secret key로부터 온체인 신원을 도출합니다. persistentHash로 secret을 domain separator와 함께 해시해, ledger에 저장할 수 있고 나중에 secret을 드러내지 않고도 검증할 수 있는 결정론적 commitment를 만듭니다.
export circuit ownerCommitment(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "leaderboard:owner:"), sk]);
}
submitScore
메인 circuit은 호출자의 점수, 표시 이름, owner commitment로 새 리더보드 entry를 만듭니다.
export circuit submitScore(
score: Uint<64>,
useCustomName: Boolean
): [] {
const sk = localSecretKey();
const ownerHash = ownerCommitment(sk);
nextId.increment(1);
const entryId = disclose(nextId.read() as Uint<64>);
if (disclose(useCustomName)) {
const customName = getCustomName();
scores.insert(entryId, ScoreEntry {
score: disclose(score),
displayName: disclose(customName),
ownerHash: disclose(ownerHash)
});
} else {
scores.insert(entryId, ScoreEntry {
score: disclose(score),
displayName: disclose(persistentHash<Bytes<32>>(sk)),
ownerHash: disclose(ownerHash)
});
}
}
localSecretKey()는 witness를 호출해 private state에서 사용자의 secret을 가져옵니다. secret은 사용자의 기기를 절대 벗어나지 않으며 온체인에도 나타나지 않습니다.
ownerCommitment(sk)는 secret을 domain separator와 함께 해시해 결정론적 신원을 만듭니다. domain separator "leaderboard:owner:"는 이 해시가 다른 용도로 쓰이는 해시와 충돌하지 않도록 보장합니다.
disclose()는 데이터를 온체인에 공개적으로 저장해도 안전하다고 표시합니다. ledger에 쓰는 모든 값은 반드시 disclose해야 합니다.
if/else 분기는 표시 이름으로 무엇을 저장할지 제어합니다. useCustomName이 true이면 컨트랙트가 getCustomName() witness를 호출해 TypeScript 호스트에서 이름을 가져옵니다. 애플리케이션 레이어는 커스텀 이름 제출과 공개 주소 제출 모두에 이 witness를 사용하며, 어떤 값을 witness로 넘길지 결정합니다. useCustomName이 false이면 컨트랙트는 witness를 호출하지 않고 표시 이름을 persistentHash(sk)로 설정합니다. 이렇게 하면 원시 해시 바이트가 만들어지고, UI는 이를 "Crimson Tiger" 같은 생성된 이름으로 렌더링합니다.
verifyOwnership
검증 circuit을 통해 사용자는 secret key를 드러내지 않고도 자신이 어떤 리더보드 entry의 소유자임을 증명할 수 있습니다.
export circuit verifyOwnership(targetEntryId: Uint<64>): [] {
assert(scores.member(disclose(targetEntryId)), "entry not found");
const entry = scores.lookup(disclose(targetEntryId));
const callerHash = ownerCommitment(localSecretKey());
assert(callerHash == entry.ownerHash, "not the owner");
}
호출자는 ownerCommitment(theirSecretKey)가 entry에 저장된 ownerHash와 일치함을 증명합니다. secret key는 사용자의 기기를 절대 벗어나지 않습니다. proof server가 로컬에서 증명을 생성합니다. 이 증명은 상금 수령, 신원 확인, 배지 시스템 제출 등에 사용할 수 있습니다. ledger에는 아무것도 기록하지 않습니다.
Compile the contract
프로젝트 의존성을 설치하고 컨트랙트를 컴파일합니다. 컴파일러는 contract/managed/leaderboard/에 TypeScript 바인딩, circuit 키, ZKIR 파일을 생성합니다.
npm install
npm run compile
예상 출력:
Compiling 2 circuits:
circuit "submitScore" (k=13, rows=4720)
circuit "verifyOwnership" (k=13, rows=2352)