For the complete documentation index, see llms.txt
Part 2: TypeScript integration
컴파일된 Compact 컨트랙트를 애플리케이션에 연결하는 TypeScript 레이어를 만듭니다. 여기에는 비공개 데이터를 ZK circuit에 전달하는 witness provider, 모든 것을 배포 가능한 단위로 묶는 컨트랙트 export, 그리고 모든 컨트랙트 상호작용을 감싸는 공용 API 클래스가 포함됩니다.
Contract package: witnesses and exports
컨트랙트 패키지에는 컴파일된 Compact 바인딩을 애플리케이션 코드에 연결하는 TypeScript 파일 두 개가 필요합니다.
Witness provider
런타임에 컨트랙트에 localSecretKey와 getCustomName을 제공하는 witness 파일을 만듭니다.
touch contract/src/witnesses.ts
localSecretKey witness는 private state에서 사용자의 secret key를 반환합니다. getCustomName witness는 커스텀 표시 이름을 반환합니다. 둘 다 [newPrivateState, value] 튜플을 반환합니다.
export type LeaderboardPrivateState = {
readonly secretKey: Uint8Array;
};
export const createLeaderboardPrivateState = (secretKey: Uint8Array): LeaderboardPrivateState => ({
secretKey,
});
let _customName = new Uint8Array(32);
export const setCustomName = (name: string): void => {
_customName = new Uint8Array(32);
_customName.set(new TextEncoder().encode(name).slice(0, 32));
};
export const createWitnesses = () => ({
localSecretKey: ({
privateState,
}: {
privateState: LeaderboardPrivateState;
}): [LeaderboardPrivateState, Uint8Array] => [privateState, privateState.secretKey],
getCustomName: ({
privateState,
}: {
privateState: LeaderboardPrivateState;
}): [LeaderboardPrivateState, Uint8Array] => [privateState, _customName],
});
Contract exports
컨트랙트 정의, witness, 컴파일된 circuit 자산을 하나의 객체로 묶는 컨트랙트 export 파일을 만듭니다.
touch contract/src/index.ts
핵심 export는 CompiledLeaderboardContract로, 컨트랙트 정의를 그 witness 및 컴파일된 circuit 파일 자산과 결합합니다.
import { CompiledContract } from '@midnight-ntwrk/compact-js';
export * as Leaderboard from '../managed/leaderboard/contract/index.js';
export { createWitnesses, setCustomName, createLeaderboardPrivateState } from './witnesses.js';
export type { LeaderboardPrivateState } from './witnesses.js';
import * as LeaderboardContract from '../managed/leaderboard/contract/index.js';
import { createWitnesses } from './witnesses.js';
export const CompiledLeaderboardContract = CompiledContract.make(
'leaderboard',
LeaderboardContract.Contract,
).pipe(
CompiledContract.withWitnesses(createWitnesses()),
CompiledContract.withCompiledFileAssets('./managed/leaderboard'),
);
컨트랙트 패키지를 빌드합니다.
cd contract && npm run build && cd ..
API package
API 패키지 디렉터리와 package.json을 만듭니다. 이 패키지는 모든 컨트랙트 상호작용을 플랫폼에 독립적인 LeaderboardAPI 클래스로 감쌉니다.
mkdir -p api/src/utils
touch api/package.json
API 패키지는 dist/로 컴파일되며, UI가 import할 수 있도록 진입점을 노출합니다.
{
"name": "@midnight-ntwrk/leaderboard-api",
"version": "0.1.0",
"private": true,
"type": "module",
"module": "./dist/index.js",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "rm -rf dist && tsc --project tsconfig.build.json"
}
}
API 패키지의 TypeScript 설정을 만듭니다. 컨트랙트 패키지와 동일한 "Bundler" 모듈 resolution을 사용합니다.
touch api/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"declaration": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src"]
}
API 패키지의 빌드 설정을 만듭니다.
touch api/tsconfig.build.json
빌드 설정은 기본 설정을 확장하며, 프로덕션 빌드에서 테스트 파일을 제외합니다.
{
"extends": "./tsconfig.json",
"exclude": ["src/test"]
}
Shared type definitions
공용 타입 정의를 만듭니다. 각 플랫폼이 충족해야 하는 provider 인터페이스와 함께, 리더보드 entry 및 파생 상태(derived state)의 타입을 정의합니다.
touch api/src/common-types.ts
MidnightProviders는 DApp에 필요한 모든 서비스를 나타냅니다. 증명 생성, 상태 저장, indexer 접근, 지갑 작업, ZK config가 여기에 해당합니다. LeaderboardCircuitKeys 타입은 컨트랙트의 circuit 이름을 나열합니다.
import { type MidnightProviders } from '@midnight-ntwrk/midnight-js-types';
import { type FoundContract } from '@midnight-ntwrk/midnight-js-contracts';
import { type LeaderboardPrivateState } from '../../contract/src/index';
export const leaderboardPrivateStateKey = 'leaderboardPrivateState';
export type PrivateStateId = typeof leaderboardPrivateStateKey;
export type LeaderboardCircuitKeys = 'submitScore' | 'verifyOwnership';
export type LeaderboardProviders = MidnightProviders<LeaderboardCircuitKeys, PrivateStateId, LeaderboardPrivateState>;
export type DeployedLeaderboardContract = FoundContract<any>;
export interface LeaderboardEntry {
readonly id: number;
readonly score: number;
readonly displayName: string;
readonly ownerHash: string;
}
export interface LeaderboardDerivedState {
readonly entryCount: number;
readonly entries: LeaderboardEntry[];
}
Display name decoder
원시 ledger 바이트를 사람이 읽을 수 있는 표시 이름으로 변환하는 유틸리티를 만듭니다.
touch api/src/utils/index.ts
이 함수는 ledger의 원시 Bytes<32>를 표시 이름으로 변환합니다. 출력 가능한 ASCII(커스텀/공개 모드)는 그대로 반환합니다. ASCII가 아닌 경우(익명 모드)는 결정론적으로 생성된 이름으로 변환합니다.
const ADJECTIVES = [
'Crimson', 'Shadow', 'Silver', 'Crystal', 'Golden', 'Ember', 'Frost', 'Storm',
'Iron', 'Cobalt', 'Jade', 'Onyx', 'Scarlet', 'Azure', 'Violet', 'Neon',
'Phantom', 'Rogue', 'Cosmic', 'Lunar', 'Solar', 'Arctic', 'Mystic', 'Nova',
'Stealth', 'Prism', 'Cipher', 'Echo', 'Apex', 'Dusk', 'Blaze', 'Volt',
];
const NOUNS = [
'Tiger', 'Phoenix', 'Wolf', 'Dragon', 'Falcon', 'Viper', 'Raven', 'Lynx',
'Panther', 'Hawk', 'Cobra', 'Mantis', 'Shark', 'Eagle', 'Jaguar', 'Owl',
'Fox', 'Bear', 'Crane', 'Orca', 'Sphinx', 'Hydra', 'Puma', 'Scorpion',
'Raptor', 'Griffin', 'Coyote', 'Badger', 'Bison', 'Condor', 'Stag', 'Wasp',
];
export const decodeDisplayName = (bytes: Uint8Array, entryId: number, score: number): string => {
const decoded = new TextDecoder().decode(bytes).replace(/\0/g, '').trim();
if (decoded.length > 0 && decoded.split('').every((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) < 127)) {
return decoded;
}
const h = (bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3]) >>> 0;
const seed = (h ^ (entryId * 2654435761) ^ (score * 1597334677)) >>> 0;
return `${ADJECTIVES[seed % ADJECTIVES.length]} ${NOUNS[(seed >>> 16) % NOUNS.length]}`;
};
LeaderboardAPI class
메인 API 파일을 만듭니다. LeaderboardAPI 클래스는 midnight-js-contracts의 deployContract와 findDeployedContract를 모든 컨트랙트 작업을 위한 단일 진입점으로 감쌉니다.
touch api/src/index.ts
submitScore 메서드는 트랜잭션 전에 조건에 따라 setCustomName을 호출합니다. !!customName 표현식은 이름의 존재 여부를 컨트랙트가 기대하는 boolean으로 변환합니다. deploy와 join 메서드는 secret을 직접 생성하지 않고 호출자로부터 secretKey를 받습니다. 이렇게 하면 브라우저 레이어가 페이지를 새로고침해도 secret을 유지할 수 있습니다.
import * as Leaderboard from '../../contract/managed/leaderboard/contract/index.js';
import { type ContractAddress } from '@midnight-ntwrk/midnight-js-protocol/compact-runtime';
import { type Logger } from 'pino';
import {
type LeaderboardDerivedState,
type LeaderboardEntry,
type LeaderboardProviders,
type DeployedLeaderboardContract,
leaderboardPrivateStateKey,
} from './common-types.js';
import { CompiledLeaderboardContract, createLeaderboardPrivateState, type LeaderboardPrivateState } from '../../contract/src/index';
import { setCustomName } from '../../contract/src/witnesses.js';
import * as utils from './utils/index.js';
import { deployContract, findDeployedContract } from '@midnight-ntwrk/midnight-js-contracts';
import { map, type Observable } from 'rxjs';
export class LeaderboardAPI {
private constructor(
public readonly deployedContract: DeployedLeaderboardContract,
providers: LeaderboardProviders,
private readonly logger?: Logger,
) {
this.deployedContractAddress = deployedContract.deployTxData.public.contractAddress;
providers.privateStateProvider.setContractAddress(this.deployedContractAddress);
this.state$ = providers.publicDataProvider
.contractStateObservable(this.deployedContractAddress, { type: 'latest' })
.pipe(
map((contractState) => Leaderboard.ledger(contractState.data)),
map((ledgerState): LeaderboardDerivedState => {
const entries: LeaderboardEntry[] = [];
for (const [key, entry] of ledgerState.scores) {
entries.push({
id: Number(key),
score: Number(entry.score),
displayName: utils.decodeDisplayName(entry.displayName, Number(key), Number(entry.score)),
ownerHash: entry.ownerHash.toString(),
});
}
entries.sort((a, b) => b.score - a.score);
return { entryCount: Number(ledgerState.nextId), entries };
}),
);
}
readonly deployedContractAddress: ContractAddress;
readonly state$: Observable<LeaderboardDerivedState>;
async submitScore(score: number, customName?: string): Promise<void> {
if (customName) { setCustomName(customName); }
await (this.deployedContract as any).callTx.submitScore(BigInt(score), !!customName);
}
async verifyOwnership(entryId: number): Promise<void> {
await (this.deployedContract as any).callTx.verifyOwnership(BigInt(entryId));
}
static async deploy(providers: LeaderboardProviders, secretKey: Uint8Array, logger?: Logger): Promise<LeaderboardAPI> {
const deployedContract = await deployContract(providers as any, {
compiledContract: CompiledLeaderboardContract,
privateStateId: leaderboardPrivateStateKey,
initialPrivateState: createLeaderboardPrivateState(secretKey),
});
return new LeaderboardAPI(deployedContract, providers, logger);
}
static async join(providers: LeaderboardProviders, contractAddress: ContractAddress, secretKey: Uint8Array, logger?: Logger): Promise<LeaderboardAPI> {
const deployedContract = await findDeployedContract(providers as any, {
contractAddress,
compiledContract: CompiledLeaderboardContract,
privateStateId: leaderboardPrivateStateKey,
initialPrivateState: createLeaderboardPrivateState(secretKey),
});
return new LeaderboardAPI(deployedContract, providers, logger);
}
}
export * as utils from './utils/index.js';
export * from './common-types.js';
두 패키지를 모두 빌드합니다.
npm run build
빌드에 성공하면 contract/와 api/ 양쪽에 dist/ 디렉터리가 생성됩니다. 다음 파트에서는 이 API를 사용하는 React 프론트엔드를 만듭니다.