The Compact JavaScript implementation
스마트 컨트랙트를 작성해 본 적이 있다면, 온체인 바이트코드로 컴파일되는 Solidity나 Rust 같은 언어에 익숙할 것입니다.
Midnight은 ZK 스마트 컨트랙트를 위해 처음부터 설계된 도메인 특화 언어 Compact를 사용합니다.
이 가이드에서는 bulletin board 컨트랙트에 대해 생성된 JavaScript 구현을 자세히 살펴봅니다.
How the JavaScript implementation gets generated
Compact 컨트랙트를 컴파일하면 ZK circuit과 함께 index.js라는 JavaScript 구현 파일이 생성됩니다.
이 구현을 통해 Node.js나 브라우저 같은 일반 JavaScript 환경에서 컨트랙트 로직을 시뮬레이션하고 테스트할 수 있습니다. 이 섹션에서는 생성 과정과 그 이유를 설명합니다.
The compilation pipeline: Compact to circuits and JavaScript implementation
컴파일 과정은 다음 단계로 진행됩니다:
Circuit 생성: 컴파일러가 .compact 파일을 파싱하고, export된 각 circuit 함수에 대한 ZK circuit을 생성합니다.
구현 파일 생성: 동시에 컨트랙트 구조를 미러링하는 JavaScript 구현 파일을 생성합니다. 컴파일러는:
- 각 circuit의 시그니처, 입력, 출력을 식별합니다.
- 정수, 불리언, 열거형, 바이트, 복합 타입 등 사용된 모든 Compact 타입의 디스크립터를 포함합니다.
- 각 circuit을 래핑하여 JavaScript에서 네이티브 값을 전달하고 상태 전환 결과를 받을 수 있게 합니다.
Compact 런타임 라이브러리 연결: 생성된 index.js는 산술 연산이나 필드 연산 같은 기본 ZK 로직을 직접 구현하지 않고, @midnight-ntwrk/compact-runtime의 공유 런타임 라이브러리를 import합니다. 이 라이브러리가 구현하는 기능:
- 유한체 산술
- 직렬화 및 역직렬화
- 에러 타입 및 타입 검사
- Circuit 관련 헬퍼 함수
생성된 파일과 런타임 라이브러리가 합쳐져 완전한 실행 환경을 구성합니다.
타입 선언: TypeScript 프로젝트에서 import할 때 타입, 자동완성, 컴파일 타임 안전성을 제공하기 위해 TypeScript 선언 파일 index.d.ts도 함께 생성됩니다.
index.js는 수동으로 작성된 파일이 아니라, Compact의 ZK circuit과 JavaScript 사이를 연결하는 자동 생성 어댑터입니다.
Compact 컨트랙트에서 함수를 추가/제거하거나 타입을 변경했다면, 재컴파일하여 index.js를 다시 생성하세요. 이 파일은 항상 자동 생성 코드로 취급하고, 직접 수정하지 마세요.
Understand the JavaScript implementation structure
Compact 컨트랙트의 생성된 구현은 managed 디렉토리 내 index.js 파일에 위치합니다.
이 파일은 타입 정의부터 호출 가능한 함수까지 컨트랙트 구조를 미러링하는 독립적인 ES 모듈입니다.
Runtime initialization and version checks
파일 최상단에서 Compact 런타임을 import하고 버전 호환성을 검증합니다:
import * as __compactRuntime from '@midnight-ntwrk/compact-runtime';
__compactRuntime.checkRuntimeVersion('0.15.0');
프로젝트에 설치된 @midnight-ntwrk/compact-runtime 버전이 컴파일러가 기대하는 버전과 일치하는지 확인합니다. 버전이 맞지 않으면 런타임 에러나 잘못된 circuit 동작이 발생할 수 있습니다.
Compact 런타임과 컴파일러 버전이 맞는지 항상 호환성 매트릭스를 확인하세요.
Type definitions and descriptors
파일에는 열거형과 타입 디스크립터가 정의되어 있습니다. 컨트랙트에서 사용하는 정수, 문자열, 커스텀 구조체 등의 데이터 타입을 인코딩/디코딩하는 방법을 지정합니다.
export var State;
(function (State) {
State[State['VACANT'] = 0] = 'VACANT';
State[State['OCCUPIED'] = 1] = 'OCCUPIED';
})(State || (State = {}));
const _descriptor_0 = new __compactRuntime.CompactTypeBytes(32);
const _descriptor_1 = __compactRuntime.CompactTypeBoolean;
const _descriptor_2 = __compactRuntime.CompactTypeOpaqueString;
const _descriptor_4 = new __compactRuntime.CompactTypeEnum(1, 1);
const _descriptor_5 = new __compactRuntime.CompactTypeUnsignedInteger(18446744073709551615n, 8);
각 디스크립터 객체는 JavaScript 값과 온체인 표현 간의 변환 방식을 정의합니다:
CompactTypeBytes(32): 32바이트 배열을 나타냅니다 (owner필드의 타입)CompactTypeBoolean: 불리언 값을 나타냅니다CompactTypeOpaqueString: 문자열 데이터를 나타냅니다 (message필드의 타입)CompactTypeEnum: State 열거형을 나타냅니다CompactTypeUnsignedInteger: Counter 및 기타 숫자 타입을 나타냅니다 (sequence필드의 타입)
Composite types and data structures
Maybe나 Either 같은 복합 Compact 타입은 기본 디스크립터를 조합하는 JavaScript 클래스로 표현됩니다.
class _Maybe_0 {
alignment() {
return _descriptor_1.alignment().concat(_descriptor_2.alignment());
}
fromValue(value_0) {
return {
is_some: _descriptor_1.fromValue(value_0),
value: _descriptor_2.fromValue(value_0)
}
}
toValue(value_0) {
return _descriptor_1.toValue(value_0.is_some).concat(_descriptor_2.toValue(value_0.value));
}
}
const _descriptor_3 = new _Maybe_0();
각 복합 타입 클래스는 JavaScript 객체와 ledger 호환 인코딩 간 변환 메서드를 제공합니다:
alignment(): ZK circuit 인코딩을 위한 필드 정렬 요구 사항을 반환합니다.fromValue(): ledger 값을 JavaScript 객체로 디코딩합니다.toValue(): JavaScript 객체를 ledger 값으로 인코딩합니다.
Maybe 타입은 bulletin board 컨트랙트의 message ledger 필드에 해당하며, 선택적 문자열 값을 나타냅니다.
The Contract class and circuit wrappers
생성된 구현은 Compact 컨트랙트의 circuit을 미러링하는 Contract 클래스를 정의합니다. 생성자에서 witnesses 객체를 검증하고 circuit 메서드를 설정합니다.
export class Contract {
witnesses;
constructor(...args_0) {
if (args_0.length !== 1) {
throw new __compactRuntime.CompactError(`Contract constructor: expected 1 argument, received ${args_0.length}`);
}
const witnesses_0 = args_0[0];
if (typeof(witnesses_0) !== 'object') {
throw new __compactRuntime.CompactError('first (witnesses) argument to Contract constructor is not an object');
}
if (typeof(witnesses_0.localSecretKey) !== 'function') {
throw new __compactRuntime.CompactError('first (witnesses) argument to Contract constructor does not contain a function-valued field named localSecretKey');
}
this.witnesses = witnesses_0;
this.circuits = {
post: (...args_1) => {
if (args_1.length !== 2) {
throw new __compactRuntime.CompactError(`post: expected 2 arguments (as invoked from TypeScript), received ${args_1.length}`);
}
const contextOrig_0 = args_1[0];
const newMessage_0 = args_1[1];
const context = { ...contextOrig_0, gasCost: __compactRuntime.emptyRunningCost() };
const partialProofData = {
input: {
value: _descriptor_2.toValue(newMessage_0),
alignment: _descriptor_2.alignment()
},
output: undefined,
publicTranscript: [],
privateTranscriptOutputs: []
};
const result_0 = this._post_0(context, partialProofData, newMessage_0);
partialProofData.output = { value: [], alignment: [] };
return { result: result_0, context: context, proofData: partialProofData, gasCost: context.gasCost };
},
takeDown: (...args_1) => {
if (args_1.length !== 1) {
throw new __compactRuntime.CompactError(`takeDown: expected 1 argument, received ${args_1.length}`);
}
const contextOrig_0 = args_1[0];
const context = { ...contextOrig_0, gasCost: __compactRuntime.emptyRunningCost() };
const partialProofData = {
input: { value: [], alignment: [] },
output: undefined,
publicTranscript: [],
privateTranscriptOutputs: []
};
const result_0 = this._takeDown_0(context, partialProofData);
partialProofData.output = { value: _descriptor_2.toValue(result_0), alignment: _descriptor_2.alignment() };
return { result: result_0, context: context, proofData: partialProofData, gasCost: context.gasCost };
},
publicKey(context, ...args_1) {
return { result: pureCircuits.publicKey(...args_1), context };
}
};
this.impureCircuits = {
post: this.circuits.post,
takeDown: this.circuits.takeDown
};
}
}
JavaScript에서 contract.circuits.post(context, newMessage)를 호출하면, 입력 타입이 자동 검증되고 ZK circuit용 데이터가 인코딩됩니다.
이후 Compact 로직을 실행하고 검증용 proofData를 반환합니다.
circuits 객체는 impure circuit(post, takeDown)과 pure circuit(publicKey)을 모두 포함합니다. impureCircuits는 witness와 상호작용하고 상태를 변경하는 circuit만 포함합니다.
Pure circuits implementation
circuit 컨텍스트 없이 직접 호출할 수 있는 pure circuit도 export합니다:
export const pureCircuits = {
publicKey(sk_0, sequence_0) {
const mem_0 = __compactRuntime.emptyMemory();
if (_descriptor_0.sizeOf(sk_0) != 32) {
__compactRuntime.valueSizeError('publicKey',
'argument 1',
'bboard.compact line 60 char 1',
'Bytes<32>',
_descriptor_0.sizeOf(sk_0),
32)
}
if (_descriptor_0.sizeOf(sequence_0) != 32) {
__compactRuntime.valueSizeError('publicKey',
'argument 2',
'bboard.compact line 60 char 1',
'Bytes<32>',
_descriptor_0.sizeOf(sequence_0),
32)
}
return __compactRuntime.persistentHash(
mem_0,
_descriptor_7,
[__compactRuntime.padStringToBytes(32, "bboard:pk:"), sequence_0, sk_0]
);
}
};
publicKey 같은 pure circuit은 ledger 상태나 witness에 접근하지 않고 결정론적 계산만 수행합니다. 소유자 커밋먼트 생성이나 해시 계산 등에 독립적으로 호출할 수 있습니다.
Ledger state deserialization
원시 ledger 상태를 타입이 지정된 JavaScript 객체로 역직렬화하는 함수도 제공합니다:
export function ledger(stateOrChargedState) {
const state = stateOrChargedState instanceof __compactRuntime.StateValue
? stateOrChargedState
: stateOrChargedState.state;
const chargedState = stateOrChargedState instanceof __compactRuntime.StateValue
? new __compactRuntime.ChargedState(stateOrChargedState)
: stateOrChargedState;
const context = {
currentQueryContext: new __compactRuntime.QueryContext(
chargedState,
__compactRuntime.dummyContractAddress()
),
costModel: __compactRuntime.CostModel.initialCostModel()
};
const partialProofData = {
input: { value: [], alignment: [] },
output: undefined,
publicTranscript: [],
privateTranscriptOutputs: []
};
return {
get state() {
return _descriptor_4.fromValue(
__compactRuntime.queryLedgerState(context, partialProofData, [
{ dup: { n: 0 } },
{
idx: {
cached: false,
pushPath: false,
path: [{
tag: 'value',
value: {
value: _descriptor_11.toValue(0n),
alignment: _descriptor_11.alignment()
}
}]
}
},
{ popeq: { cached: false, result: undefined } }
]).value
);
},
// 각 ledger 필드에 대한 유사한 getter 구현
// 각각 적절한 필드 인덱스와 함께 queryLedgerState를 사용
};
}
블록체인의 원시 컨트랙트 상태를 타입이 지정된 Ledger 객체로 변환합니다:
- indexer에서
StateValue또는ChargedState를 받습니다. - 비용 추적이 포함된 쿼리 컨텍스트를 생성합니다.
- 각 ledger 필드(state, message, sequence, owner)에 대한 getter를 가진 객체를 반환합니다.
- 각 getter는 필드 인덱스 경로와 함께
queryLedgerState를 사용하여 값을 지연 로드합니다. - DApp은 이 함수로 indexer가 반환한 컨트랙트 상태를 해석합니다.
Exports and type bindings
컨트랙트 상호작용에 필요한 모든 것을 export합니다:
export class Contract { ... }
export var State;
export const pureCircuits = { ... };
export function ledger(state) { ... }
index.d.ts 파일에서 TypeScript 타입 정의를 제공합니다:
export enum State { VACANT = 0, OCCUPIED = 1 }
export type Maybe<T> = { is_some: boolean; value: T };
export type Witnesses<PS> = {
localSecretKey(context: __compactRuntime.WitnessContext<Ledger, PS>): [PS, Uint8Array];
}
export type ImpureCircuits<PS> = {
post(context: __compactRuntime.CircuitContext<PS>, newMessage_0: string): __compactRuntime.CircuitResults<PS, []>;
takeDown(context: __compactRuntime.CircuitContext<PS>): __compactRuntime.CircuitResults<PS, string>;
}
export type PureCircuits = {
publicKey(sk_0: Uint8Array, sequence_0: Uint8Array): Uint8Array;
}
export type Ledger = {
readonly state: State;
readonly message: Maybe<string>;
readonly sequence: bigint;
readonly owner: Uint8Array;
}
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>;
}
export declare function ledger(state: __compactRuntime.StateValue | __compactRuntime.ChargedState): Ledger;
export declare const pureCircuits: PureCircuits;
이 타입 정의 덕분에 TypeScript 프로젝트에서 타입 안전하게 컨트랙트와 상호작용할 수 있습니다. IDE에서 사용 가능한 함수와 구조를 인식하여 자동완성과 컴파일 타임 에러 검사가 가능합니다.
Next steps
Compact JavaScript 구현의 생성 과정을 살펴보았습니다. Compact JavaScript 구현 사용하기 가이드에서 실제 사용 방법을 확인하세요.