Bulletin board contract
이 튜토리얼에서는 Midnight 블록체인에서 프라이버시를 보호하는 게시판 스마트 컨트랙트를 구축하는 방법을 보여줍니다.
게시판 컨트랙트는 사용자가 프라이버시를 유지하면서 메시지를 게시하고 제거할 수 있게 합니다. 원래 게시자만 메시지를 제거할 수 있으며, 이는 게시자의 신원을 온체인에 공개하지 않고 ZK proof를 통해 시행됩니다.
Prerequisites
시작하기 전에 다음 사항을 확인하세요:
- Compact 툴체인 설치: 설치 방법은 툴체인 설치 가이드를 참조하세요
- proof 서버 실행 중: 설치 방법은 proof 서버 실행 가이드를 참조하세요
- Node.js 버전 22 이상:
node --version으로 확인하세요
Set up the project
프로젝트 루트와 컨트랙트 디렉터리를 생성합니다:
mkdir -p example-bboard/contract/src
cd example-bboard/contract
디렉터리 구조는 다음과 같아야 합니다:
example-bboard/
└── contract/
└── src/
Write the smart contract
이 섹션에서는 스마트 컨트랙트를 작성하는 과정과 핵심 개념을 설명합니다.
Create the contract file
contract/src/bboard.compact를 생성합니다:
touch src/bboard.compact
코드 에디터에서 이 파일을 엽니다.
Add the language version
pragma language_version 지시문은 컨트랙트에서 사용하는 Compact 버전을 지정합니다:
pragma language_version >= 0.22;
이 지시문은:
- 컨트랙트를 최소 Compact 버전에 고정합니다
- 향후 컴파일러 버전의 호환성 문제를 방지합니다
Import the standard library
내장 타입과 함수를 위해 Compact의 표준 라이브러리를 임포트합니다:
pragma language_version >= 0.22;
import CompactStandardLibrary;
CompactStandardLibrary는 Compact의 내장 타입과 함수에 대한 접근을 제공합니다. 선택적 값을 위한 Maybe, 시퀀스 추적을 위한 Counter, 암호학적 commitment를 위한 persistentHash, Maybe 값의 some 및 none 생성자 등이 포함됩니다.
표준 라이브러리에서 사용 가능한 기능에 대해 자세히 알아보려면 Compact 표준 라이브러리 참조를 확인하세요.
Define the board state enum
게시판은 두 가지 가능한 상태를 가집니다. 이를 나타내는 열거형 타입을 정의합니다:
pragma language_version >= 0.22;
import CompactStandardLibrary;
export enum State {
VACANT,
OCCUPIED
}
이 열거형은:
export는 TypeScript에서 열거형에 접근 가능하게 합니다State는 열거형 타입 이름입니다VACANT은 빈 게시판을 나타냅니다 (값 0)OCCUPIED는 메시지가 있는 게시판을 나타냅니다 (값 1)
Define the ledger state
ledger는 컨트랙트의 공개 온체인 상태를 나타냅니다. 게시판에는 네 가지 공개 정보가 필요합니다:
pragma language_version >= 0.22;
import CompactStandardLibrary;
export enum State {
VACANT,
OCCUPIED
}
export ledger state: State;
export ledger message: Maybe<Opaque<'string'>>;
export ledger sequence: Counter;
export ledger owner: Bytes<32>;
각 ledger 필드는 특정 목적을 수행합니다:
state: 게시판이 비어 있는지 점유되어 있는지 추적합니다message: 현재 메시지를 선택적 Opaque 문자열로 저장합니다sequence: 메시지가 제거될 때마다 증가하는 카운터로, 각 게시 주기에 고유한 commitment를 생성하고 재생 공격을 방지합니다owner: 게시자 신원에 대한 암호학적 commitment를 32바이트 해시로 저장합니다
Maybe 타입은 선택적 값을 나타냅니다. 메시지는 게시판이 비어 있을 때 none이고 점유되어 있을 때 some(value)입니다. Opaque<'string'> 타입은 내부 구조가 컨트랙트와 관련 없는 문자열 데이터를 나타냅니다.
Create the constructor
생성자는 컨트랙트가 배포될 때 ledger 상태를 초기화합니다:
// ... 이전 코드 ...
constructor() {
state = State.VACANT;
message = none<Opaque<'string'>>();
sequence.increment(1);
}
이 생성자는 명시적으로 초기화할 항목과 기본값에 맡길 항목을 의도적으로 구분합니다:
state = State.VACANT: 열거형 순서와 독립적으로 컨트랙트를 만들기 위해 명시적으로 설정message = none<Opaque<'string'>>(): 표준 라이브러리의Maybe기본 구현과 분리하기 위해 명시적으로 설정sequence.increment(1): 0에서 1로 증가시켜, 게시물이 시퀀스 번호 1부터 시작하도록 합니다owner: 초기화되지 않음, 언어가 보장하는 32 제로 바이트 기본값을 사용
패턴 요약: 라이브러리나 열거형 정의에 의존하는 값은 명시적으로 초기화하고, 언어 사양이 보장하는 기본값은 그대로 활용합니다.
생성자에서 명시적으로 초기화되지 않은 필드는 Compact 언어 사양에 정의된 타입의 기본값을 받습니다.
Declare the witness function
circuit를 정의하기 전에 witness 함수를 선언합니다:
// ... 이전 코드 ...
witness localSecretKey(): Bytes<32>;
이 선언은:
witness는 임의의 계산을 수행하는 TypeScript 또는 JavaScript로 구현된 함수로 표시합니다.localSecretKey는 사용자의 비밀 키의 32바이트 값을 반환합니다.- 반환 값은 기본적으로 private이며 온체인이나 공개 ledger 상태에 나타나지 않습니다.
Witness는 프라이버시를 보호하는 계산을 가능하게 합니다. private 상태 접근이나 값 생성 같은 모든 계산을 수행할 수 있으며, circuit가 proof 생성에 사용하는 결과를 반환합니다.
Create the post circuit
post circuit는 사용자가 빈 게시판에 메시지를 게시할 수 있게 합니다:
// ... 이전 코드 ...
export circuit post(newMessage: Opaque<'string'>): [] {
assert(state == State.VACANT, "Attempted to post to an occupied board");
owner = disclose(publicKey(localSecretKey(), sequence as Field as Bytes<32>));
message = disclose(some<Opaque<'string'>>(newMessage));
state = State.OCCUPIED;
}
이 circuit는 다음 작업을 순서대로 처리합니다:
- 게시판 상태 검증:
assert(state == State.VACANT, ...)는 게시판이 비어 있을 때만 게시가 발생하도록 합니다. - commitment 생성:
publicKey()헬퍼 circuit를 호출하여 비밀 키와 현재 시퀀스 번호로 암호학적 commitment를 생성합니다. 이것이 원래 게시자만 메시지를 제거할 수 있게 하는 것입니다. - Disclose: commitment를
disclose()로 래핑하여 이 값을 온체인에 공개해도 안전하다고 컴파일러에 알립니다. - 메시지 저장:
message를some(newMessage)로 설정하고 명시적으로 공개하기 위해disclose()로 래핑합니다. - 상태 업데이트:
state = State.OCCUPIED로 변경하여 게시판이 점유되었음을 표시합니다.
disclose 키워드는 보안에 매우 중요합니다.
기본적으로 Compact는 계산된 값이 공개 ledger 필드에 할당되는 것을 방지합니다.
온체인에 공개해도 안전하다고 표시하려면 값을 disclose()로 반드시 명시적으로 래핑해야 하며,
이를 통해 실수로 private 데이터를 유출하지 않도록 보장합니다.
시퀀스 카운터는 두 번 캐스팅됩니다(sequence as Field as Bytes<32>). Counter에서 Bytes<32>로 직접 캐스팅할 수 없기 때문입니다. 중간 Field 캐스팅이 호환 가능한 타입 경로를 제공합니다.
Create the takeDown circuit
takeDown circuit는 사용자가 자신의 메시지를 제거할 수 있게 합니다:
// ... 이전 코드 ...
export circuit takeDown(): Opaque<'string'> {
assert(state == State.OCCUPIED, "Attempted to take down post from an empty board");
assert(owner == publicKey(localSecretKey(), sequence as Field as Bytes<32>), "Attempted to take down post, but not the current owner");
const formerMsg = message.value;
state = State.VACANT;
sequence.increment(1);
message = none<Opaque<'string'>>();
return formerMsg;
}
이 circuit는 다음 작업을 순서대로 처리합니다:
- 게시판 상태 검증: 제거를 시도하기 전에 게시판이 점유되어 있는지 확인합니다
- commitment 재생성: 현재 사용자의 비밀 키와 시퀀스 번호로
publicKey()를 호출합니다 - 소유권 확인: 재생성된 commitment를 저장된
owner값과 비교합니다 - 메시지 추출:
message.value를 사용하여Maybe타입에서 내부 값에 접근합니다 - 상태 업데이트:
state를State.VACANT으로 변경합니다 - 시퀀스 증가: 다음 게시물이 시퀀스 번호 2, 3 등을 사용하도록 카운터를 증가시킵니다
- 메시지 초기화:
message를none으로 재설정합니 다 - 메시지 반환: 제거된 메시지를 호출자에게 반환합니다
두 번째 assert가 프라이버시와 접근 제어가 만나는 지점입니다. 사용자는 비밀 키를 공개하지 않고 저장된 commitment를 재생성할 수 있음을 proof합니다. ZK proof는 private 데이터를 노출하지 않고 이 assertion을 검증합니다.
값을 추출한 후 message가 초기화됩니다. 이를 통해 게시판이 다음 게시물을 위해 준비됩니다.
Create the publicKey helper circuit
publicKey circuit는 게시자의 신원에 대한 암호학적 commitment를 생성합니다:
// ... 이전 코드 ...
export circuit publicKey(sk: Bytes<32>, sequence: Bytes<32>): Bytes<32> {
return persistentHash<Vector<3, Bytes<32>>>([pad(32, "bboard:pk:"), sequence, sk]);
}
이 헬퍼 circuit는:
pad(32, "bboard:pk:")를 사용하여 도메인 분리자를 생성합니다- 해시할 세 개의 32바이트 값 벡터를 생성합니다
- 표준 라이브러리의
persistentHash를 사용하며, SHA-256 해싱을 구현합니다 - 입력 값(
sk)과 시퀀스 번호를 매개변수로 받습니다 - 32바이트 암호학적 해시를 반환합니다
commitment는 중요한 특 성을 가집니다:
- 도메인 분리:
"bboard:pk:"접두사는 비밀 키의 다른 용도와의 해시 충돌을 방지합니다 - 단방향: SHA-256은 암호학적으로 역전이 불가능하여, 해시 출력에서 입력을 발견할 수 없습니다
- 결정적: 동일한 입력은 항상 동일한 출력을 생성합니다
- 게시물별 고유: 시퀀스 번호는 각 게시물이 다른 commitment를 갖도록 보장합니다
도메인 분리자는 보안 모범 사례입니다. 동일한 비밀 키를 사용하더라도 게시판을 위해 생성된 해시가 다른 목적으로 생성된 해시와 혼동되지 않도록 보장합니다.
완성된 게시판 컨트랙트는 다음과 같아야 합니다:
pragma language_version >= 0.22;
import CompactStandardLibrary;
export enum State {
VACANT,
OCCUPIED
}
export ledger state: State;
export ledger message: Maybe<Opaque<'string'>>;
export ledger sequence: Counter;
export ledger owner: Bytes<32>;
constructor() {
state = State.VACANT;
message = none<Opaque<'string'>>();
sequence.increment(1);
}
witness localSecretKey(): Bytes<32>;
export circuit post(newMessage: Opaque<'string'>): [] {
assert(state == State.VACANT, "Attempted to post to an occupied board");
owner = disclose(publicKey(localSecretKey(), sequence as Field as Bytes<32>));
message = disclose(some<Opaque<'string'>>(newMessage));
state = State.OCCUPIED;
}
export circuit takeDown(): Opaque<'string'> {
assert(state == State.OCCUPIED, "Attempted to take down post from an empty board");
assert(owner == publicKey(localSecretKey(), sequence as Field as Bytes<32>), "Attempted to take down post, but not the current owner");
const formerMsg = message.value;
state = State.VACANT;
sequence.increment(1);
message = none<Opaque<'string'>>();
return formerMsg;
}
export circuit publicKey(sk: Bytes<32>, sequence: Bytes<32>): Bytes<32> {
return persistentHash<Vector<3, Bytes<32>>>([pad(32, "bboard:pk:"), sequence, sk]);
}
Compile the contract
컴파일은 Compact 코드를 ZK circuit로 변환하고 컨트랙트와 상호작용하기 위한 TypeScript API를 생성합니다.
Run the compiler
contract 디렉터리에서 컨트랙트를 컴파일합니다:
compact compile src/bboard.compact src/managed/bboard
이 명령은 세 부분으로 구성됩니다:
compact compile은 Compact 컴파일러를 호출합니다.src/bboard.compact는 컴파일할 소스 파일을 지정합니다.src/managed/bboard는 생성된 파일의 출력 디렉터리를 지정합니다.
다음과 유사한 출력이 표시됩니다:
Compiling 2 circuits:
circuit "post" (k=13, rows=4569)
circuit "takeDown" (k=13, rows=4580)
Overall progress [====================] 2/2
Examine the generated files
컴파일 후, src/managed/bboard 디렉터리에는 다음이 포함됩니다:
src/managed/bboard/
├── contract/
│ ├── index.d.ts # 타입 정의
│ ├── index.js # JavaScript 구현
│ └── index.js.map
├── keys/ # 암호학적 키
│ ├── post.prover
│ ├── post.verifier
│ ├── takeDown.prover
│ ├── takeDown.verifier
├── zkir/ # ZK 중간 표현
│ ├── post.zkir
│ ├── post.bzkir
│ ├── takeDown.zkir
│ ├── takeDown.bzkir
└── compiler/ # 컴파일러 메타데이터
└── contract-info.json
각 디렉터리의 용도는 다음과 같습니다:
contract/: DApp이 컨트랙트와 상호작용하는 데 사용하는 생성된 TypeScript API 및 JavaScript 구현을 포함합니다keys/: 각 circuit에 대한 ZK proof 생성 및 검증에 사용되는 암호학적 키zkir/: proof 서버가 사용하는 중간 circuit 표현compiler/: circuit, 타입 및 구조에 대한 메타데이터 (JSON 형식)
Understand the generated API
Compact 컴파일러는 컨트랙트 코드에 대응하는 TypeScript 정의를 생성합니다. managed/bboard/contract/index.d.ts를 열어 생성된 타입을 확인하세요.
State type
Compact 코드의 State 열거형은 TypeScript 열거형이 됩니다:
export enum State {
VACANT = 0,
OCCUPIED = 1
}
이를 통해 TypeScript 코드에서 State.VACANT과 State.OCCUPIED를 사용하여 게시판 상태를 타입 안전하게 참조할 수 있습니다.
Circuit types
Circuits 타입은 호출 가능한 함수를 정의합니다:
export type Circuits<PS> = {
post(
context: __compactRuntime.CircuitContext<PS>,
newMessage: string
): __compactRuntime.CircuitResults<PS, []>;
takeDown(
context: __compactRuntime.CircuitContext<PS>
): __compactRuntime.CircuitResults<PS, string>;
publicKey(
context: __compactRuntime.CircuitContext<PS>,
sk: Uint8Array,
sequence: Uint8Array
): __compactRuntime.CircuitResults<PS, Uint8Array>;
}
각 circuit 메서드는:
- Compact 코드 에서 내보낸 circuit에 대응합니다
- ledger 상태와 witness 함수에 대한 접근을 제공하는
CircuitContext를 받습니다 - Compact circuit 매개변수와 일치하는 매개변수를 받습니다
- ZK proof를 생성하기 위해 proof 서버로 전송되는
ProofData를 포함하는CircuitResults를 반환합니다 - Compact 타입에 적합한 JavaScript 타입(
string,Uint8Array)을 사용합니다
Ledger types
Ledger 타입은 공개 상태 구조를 정의합니다:
export type Ledger = {
readonly state: State;
readonly message: { is_some: boolean, value: string };
readonly sequence: bigint;
readonly owner: Uint8Array;
}
각 필드는:
- Compact 코드의 ledger 선언에 대응합니다
- JavaScript 타입을 사용합니다:
State열거형,string,bigint,Uint8Array Maybe타입을is_some: boolean과value속성이 있는 객체로 표현합니다readonly로 표시되어, 상태 수정은 circuit 호출을 통해서만 가능합니다
TypeScript 코드에서 message ledger 필드에 Maybe 타입 주석을 사용하려면 Compact 컨트랙트에서 내보내세요:
export { Maybe };
Witness types
Witnesses 타입은 필요한 witness 구현을 정의합니다:
export type Witnesses<PS> = {
localSecretKey(context: __compactRuntime.WitnessContext<Ledger, PS>): [PS, Uint8Array];
}
이 타입은:
- Compact 코드의
witness localSecretKey()선언에 대응합니다 - ledger 상태, private 상태, 컨트랙트 주소에 대한 접근을 제공하는
WitnessContext를 받습니다 - 업데이트된 private 상태와 32바이트 비밀 키를 포함하는 튜플
[PS, Uint8Array]를 반환합니다 - circuit 실행 중 private 데이터를 제공하기 위해 DApp에서 구현해야 합니다
Contract type
Contract 클래스는 모든 것을 하나로 묶습니다:
export declare class Contract<PS = any, W extends Witnesses<PS> = Witnesses<PS>> {
witnesses: W;
circuits: Circuits<PS>;
impureCircuits: ImpureCircuits<PS>;
constructor(witnesses: W);
initialState(context: __compactRuntime.ConstructorContext<PS>): __compactRuntime.ConstructorResult<PS>;
}
Contract 클래스는 컴파일된 컨트랙트와 상호작용하기 위한 메인 인터페이스를 제공합니다:
- private 상태를 위한
PS와 witness를 위한W타입 매개변수를 사용합니다 - 순수 circuit 함수를 위한
circuits를 제공합니다 - witness와 상호작용하는 circuit를 위한
impureCircuits를 제공합니다 - 생성자에서 witness 구현을 받습니다
initialState를 통해 컨트랙트 상태를 초기화하며, 이 메서드는 생성자를 호출합니다
Implement witness functions
게시판 컨트랙트는 circuit 실행 중 사용자의 비밀 키에 대한 접근을 제공하기 위한 witness 구현이 필요합니 다.
Create the witnesses file
contract/src/witnesses.ts를 생성합니다:
import { Ledger } from "./managed/bboard/contract/index.js";
import { WitnessContext } from "@midnight-ntwrk/compact-runtime";
export type BBoardPrivateState = {
readonly secretKey: Uint8Array;
};
export const createBBoardPrivateState = (secretKey: Uint8Array) => ({
secretKey,
});
export const witnesses = {
localSecretKey: ({
privateState,
}: WitnessContext<Ledger, BBoardPrivateState>): [
BBoardPrivateState,
Uint8Array,
] => [privateState, privateState.secretKey],
};
이 코드는 다음을 정의합니다:
BBoardPrivateState:Uint8Array타입의secretKey필드를 가진 private 상태 타입.createBBoardPrivateState(): 비밀 키로 private 상태를 초기화하는 헬퍼 함수.witnesses.localSecretKey:WitnessContext<Ledger, BBoardPrivateState>를 받고[BBoardPrivateState, Uint8Array]튜플을 반환하는 구현. 이 함수는 컨텍스트에서privateState를 추출하고 변경되지 않은 private 상태와 비밀 키를 모두 반환합니다.
witness 함수는 ledger 상태, private 상태, 컨트랙트 주소에 대한 접근을 제공하는 WitnessContext 매개변수를 받습니다. Compact 런타임은 circuit 실행 중 이 컨텍스트를 자동으로 전달합니다.
WitnessContext 타입은 Compact 런타임 API의 일부입니다. witness 컨텍스트 및 기타 런타임 타입에 대한 자세한 정보는 Compact 런타임 API 문서를 참조하세요.
Create the index file
컨트랙트 API를 재내보내기 위해 contract/src/index.ts를 생성합니다:
export * from "./managed/bboard/contract/index.js";
export * from "./witnesses";
import * as CompiledBBoardContract from "./managed/bboard/contract/index.js";
import * as Witnesses from "./witnesses";
import { CompiledContract } from "@midnight-ntwrk/compact-js";
export const CompiledBBoardContractContract = CompiledContract.make<
CompiledBBoardContract.Contract<Witnesses.BBoardPrivateState>
>(
"BBoard",
CompiledBBoardContract.Contract<Witnesses.BBoardPrivateState>,
).pipe(
CompiledContract.withWitnesses(Witnesses.witnesses),
CompiledContract.withCompiledFileAssets("./compiled/bboard"),
);
이 파일은 게시판 컨트랙트의 메인 진입점 역할을 합니다. 생성된 컨트랙트 코드와 witness 구현의 모든 타입과 함수를 재내보내기하여 소비 애플리케이션을 위한 단일 임포트 포인트를 제공합니다.
Initialize the npm package
컨트랙트에는 의존성 관리, 빌드 스크립트 정의, DApp에서 사용할 컨트랙트 패키징을 위한 package.json 파일이 필요합니다. 이를 통해 컨트랙트를 재사용 가능한 모듈로 쉽게 컴파일, 빌드, 배포할 수 있습니다.
contract 디렉터리에 package.json 파일을 생성합니다:
npm init -y
기본값이 포함된 기본 package.json 파일이 생성되며, 이후 단계에서 컴파일 스크립트와 패키지 메타데이터를 추가하여 커스터마이즈합니다.
Configure TypeScript
contract 디렉터리에 tsconfig.json 파일을 생성합니다:
{
"include": ["src/**/*.ts"],
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"lib": ["ESNext"],
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strict": true,
"isolatedModules": true,
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
주요 설정 옵션은 다음과 같습니다:
target과module: 최신 JavaScript 기능을 위해 ES2022로 설정declaration: TypeScript 소비자를 위한.d.ts타입 정의 파일 생성outDir: 컴파일된 JavaScript 파일은./dist에 저장rootDir: 소스 TypeScript 파일은./src에 위치strict: 더 나은 코드 품질을 위한 엄격한 타입 검사 활성화
Add build scripts
빌드 스크립트를 포함하도록 contract/package.json을 업데이트합니다:
{
"name": "@midnight-ntwrk/bboard-contract",
"version": "0.1.0",
"license": "Apache-2.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"
}
},
"scripts": {
"clean": "rm -rf dist managed",
"compile:compact": "compact compile src/bboard.compact src/managed/bboard",
"compile:typescript": "tsc",
"build": "npm run clean && npm run compile:compact && npm run compile:typescript && cp -Rf ./src/managed ./dist/managed && cp ./src/bboard.compact ./dist"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.8.0"
}
}
각 스크립트의 용도는 다음과 같습니다:
clean: 새로운 빌드를 위해 컴파일된 출력을 제거compile:compact: Compact 컴파일러를 실행하여 circuit 및 TypeScript API 생성compile:typescript: TypeScript를 JavaScript로 컴파일build: 모든 컴파일 단계를 순서대로 실행하고 필요한 파일을 복사
Build the contract
전체 빌드 프로세스를 실행합니다:
npm install
npm run build
이 명령은:
- 의존성(TypeScript 컴파일러 및 Node.js 타입)을 설치합니다
- 이전 빌드 아티팩트를 정리합니다
- Compact 컨트랙트를 circuit 및 TypeScript로 컴파일합니다
- TypeScript를 JavaScript로 컴파일합니다
- 타입 정의 파일을 생성합니다
- 관리 코드와 소스 컨트랙트를
dist디렉터리에 복사합니다
Compact 컴파일러와 TypeScript 컴파일러 모두의 출력이 표시됩니다. 성공하면 다음을 갖게 됩니다:
dist/managed/bboard/: 생성된 컨트랙트 코드dist/: 컴파일된 JavaScript 및 타입 정의dist/bboard.compact: 소스 컨트랙트 파일
Next steps
게시판 컨트랙트를 빌드하고 컴파일했습니다:
- CLI 구축: 대화형 명령줄 인터페이스를 생성하려면 게시판 CLI 구축으로 계속 진행하세요
- 컨트랙트 테스트:
src/test/에 단위 테스트를 추가하여 circuit 동작 및 commitment 생성을 검증하세요