Skip to main content

Compact 심층 분석 파트 1 — 컨트랙트의 최상위 구조

· 10 min read
Kevin Millikin
Language Design Manager

이 글은 Midnight 네트워크에서 Compact contract가 어떻게 작동하는지 탐구하는 Compact Deep Dive 시리즈의 일부입니다. 각 글은 서로 다른 기술적 주제에 초점을 맞추며 독립적으로 읽을 수 있지만, 전체를 함께 읽으면 Compact이 실제로 어떻게 작동하는지 더 완전한 그림을 얻을 수 있습니다. 이 글은 개발자 튜토리얼에서 다루는 수준까지 Compact에 익숙하다고 가정합니다. 이 시리즈의 일부 글에서는 ZK proof와 Midnight 온체인 런타임의 구현 세부 사항을 깊이 있게 다룹니다.

이 내용은 현재 아키텍처를 반영하지만, 저수준 메커니즘을 다루는 만큼 플랫폼 발전에 따라 변경될 수 있습니다.

주의할 점은 여기서 다루는 거의 모든 내용이 구현 세부 사항이라는 것입니다. 이는 안정적이지 않으며, 필요에 따라 언제든 변경될 수 있습니다.

Overview of the Bulletin Board Contract

익숙한 Bulletin Board를 예시로 사용하겠습니다. Compact 툴체인 버전 0.24.0으로 컴파일합니다. Compact 언어는 기능이 추가되고 변경되면서 계속 진화하고 있으므로, 이 코드는 0.24.0 이외의 다른 Compact 툴체인 버전에서는 컴파일되지 않을 수 있습니다.

note

이 섹션의 모든 코드는 Compact 코드입니다.

pragma language_version 0.16;

import CompactStandardLibrary;

export enum State {
VACANT,
OCCUPIED
}

export ledger state: State;

export ledger message: Maybe<Opaque<"string">>;

export ledger instance: Counter;

export ledger poster: Bytes<32>;

constructor() {
state = State.VACANT;
message = none<Opaque<"string">>();
instance.increment(1);
}

witness localSecretKey(): Bytes<32>;

export circuit post(newMessage: Opaque<"string">): [] {
assert(state == State.VACANT, "Attempted to post to an occupied board");
poster = disclose(publicKey(localSecretKey(), instance 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(poster == publicKey(localSecretKey(), instance as Field as Bytes<32>), "Attempted to take down post, but not the current poster");
const formerMsg = message.value;
state = State.VACANT;
instance.increment(1);
message = none<Opaque<"string">>();
return formerMsg;
}

export circuit publicKey(sk: Bytes<32>, instance: Bytes<32>): Bytes<32> {
return persistentHash<Vector<3, Bytes<32>>>([pad(32, "bboard:pk:"), instance, sk]);
}

post circuit에 집중하겠습니다. 먼저 contract의 기본 사항을 다시 살펴보겠습니다. Bulletin board의 상태를 위한 Compact enum 타입을 선언하고, 몇 가지 ledger 필드를 선언합니다. 세 개는 변경 가능한 ledger 셀이고 하나는 Counter입니다:

export enum State {
VACANT,
OCCUPIED
}

export ledger state: State;

export ledger message: Maybe<Opaque<"string">>;

export ledger instance: Counter;

export ledger poster: Bytes<32>;

Private state에 접근하는 witness가 있습니다. 이것은 JavaScript 또는 TypeScript로 구현된 외부 함수입니다. 사용자의 비밀키를 어떤 방식으로든 조회하여 반환합니다. post circuit은 bulletin board가 VACANT 상태인지 확인하고, 그렇다면 witness를 사용하여 비밀키를 가져온 후 ledger의 세 셀을 업데이트합니다:

witness localSecretKey(): Bytes<32>;

export circuit post(newMessage: Opaque<"string">): [] {
assert(state == State.VACANT, "Attempted to post to an occupied board");
poster = disclose(publicKey(localSecretKey(), instance as Field as Bytes<32>));
message = disclose(some<Opaque<"string">>(newMessage));
state = State.OCCUPIED;
}

posttakeDown circuit 모두에서 사용되는 publicKey라는 헬퍼 circuit도 있습니다. publicKey를 exported로 선언하면 두 가지 효과가 있습니다:

  • TypeScript 또는 JavaScript에서 circuit을 호출할 수 있게 됩니다.
  • Contract의 진입점이 되어 publicKey transaction을 Midnight blockchain에 제출할 수 있게 됩니다.
export circuit publicKey(sk: Bytes<32>, instance: Bytes<32>): Bytes<32> {
return persistentHash<Vector<3, Bytes<32>>>([pad(32, "bboard:pk:"), instance, sk]);
}

A Look Under the Hood

compact이 path에 있다면, Bulletin Board contract를 직접 컴파일할 수 있습니다. 코드가 bboard.compact라는 파일에 있다면, 해당 파일이 위치한 디렉토리로 이동하세요. 그런 다음 compact compile을 실행하면서 소스 파일과 출력 디렉토리를 전달합니다:

$ compact compile bboard.compact bboard-out
Compact version: 0.24.0
Compiling 2 circuits:
circuit "post" (k=14, rows=10070)
circuit "takeDown" (k=14, rows=10087)
Overall progress [====================] 2/2

컴파일러가 생성한 내용을 살펴보겠습니다:

$ ls bboard-out
compiler contract keys zkir

네 개의 하위 디렉토리가 있습니다. compiler에는 composable contract에서 사용될 contract 메타데이터가 있으며, 지금은 무시해도 됩니다. keyszkir은 ZK proof와 관련되어 있으며 이후 글에서 다루겠습니다. 컴파일러는 contract 코드를 JavaScript 구현으로 변환했으며, 이것은 contract 하위 디렉토리에 있습니다. 먼저 이 부분에 집중하겠습니다.

$ ls bboard-out/contract
index.cjs index.cjs.map index.d.cts

세 개의 파일이 있습니다. index.cjs는 contract의 JavaScript 구현입니다. (JavaScript 소스 코드이며, .cjs 확장자는 CommonJS 모듈 시스템을 사용한다는 의미입니다.) index.cjs.map은 디버깅에 사용할 수 있는 소스 맵 파일입니다. index.cjs의 JavaScript 구현을 bboard.compact에 있던 원래 Compact 소스 코드에 연결합니다. 마지막으로, JavaScript 구현을 위한 TypeScript 선언 파일 index.d.cts가 있습니다. 이를 통해 index.cjs의 JavaScript 코드를 TypeScript에서 호출할 수 있고(그리고 중요하게도, TypeScript 컴파일러가 타입을 검사할 수 있습니다).

순수 TypeScript 구현 대신 TypeScript 선언 파일이 있는 JavaScript 구현을 선택한 이유가 몇 가지 있습니다. 첫째, TypeScript 타입 시스템이 검사하지 못하는 부분에 대한 런타임 검사를 JavaScript 코드에 삽입할 수 있습니다. 둘째, Compact 소스 코드까지 매핑되는 디버깅용 소스 맵을 생성할 수 있습니다. TypeScript 컴파일러가 생성하는 소스 맵은 TypeScript 컴파일러의 JavaScript 출력을 Compact 컴파일러가 생성한 TypeScript에만 연결하며, 원래 Compact 소스 코드까지 연결하지는 않습니다.

TypeScript Declaration

이제 TypeScript 선언 파일 index.d.cts를 살펴보면서 contract 구현의 구조를 파악해 보겠습니다. 이 파일을 순서대로가 아닌 방식으로 살펴보겠습니다. 더 읽기 전에, 코드를 직접 컴파일하여 이 파일을 생성하고 직접 살펴보시기를 권장합니다.

이 파일은 Compact 툴체인 버전 0.24.0으로 생성되었습니다. 다른 버전으로 같은 작업을 시도하면, 다른 구현 세부 사항을 보게 될 수 있습니다.

The Compact Runtime

note

이 섹션의 모든 코드는 TypeScript로 작성되었습니다.

가장 먼저 보게 되는 것은:

import type * as __compactRuntime from '@midnight-ntwrk/compact-runtime';

이것은 Compact 컴파일러가 생성한 JavaScript 코드에서 사용하는 API인 Node.js 패키지 @midnight-ntwrk/compact-runtime을 임포트합니다. 이를 분리함으로써 런타임과 컴파일러 구현이 디커플링되며, 생성된 JavaScript 코드가 더 작아질 수 있습니다. 이후에 Compact 런타임이 상당히 복잡하다는 것을 알게 될 것입니다(Rust로 구현되어 WebAssembly로 컴파일된 온체인 런타임의 상당 부분을 재내보내기합니다).

필요하다면 DApp에서 이 패키지를 직접 임포트하여 Compact 런타임 타입과 함수에 접근할 수도 있습니다. Compact 런타임의 API 문서는 Midnight 문서에서 확인할 수 있습니다.

Compact enum Types

Bulletin board contract는 게시판 상태(vacant 또는 occupied)를 위한 Compact enum 타입을 선언했습니다. 이 타입은 (export 키워드를 통해) exported되어 DApp의 TypeScript 또는 JavaScript 구현에서 사용할 수 있으므로, TypeScript 선언 파일에 선언이 있습니다:

export enum State { VACANT = 0, OCCUPIED = 1 }

enum 타입이 Compact에서 exported되지 않았다면 이 선언을 볼 수 없을 것입니다. 그러면 이 타입이 contract의 API(circuit 파라미터나 ledger 등)에 나타날 때마다 기본 TypeScript 표현 타입인 number가 대신 표시됩니다. (직접 시도해 보세요! Compact contract에서 enumexport 키워드를 제거해 보세요. 생성된 TypeScript나 JavaScript contract 코드만 보고 싶다면, Compact 컴파일러에 --skip-zk 명령줄 플래그를 전달하여 ZK 키 생성을 건너뛸 수 있습니다. 훨씬 빠르게 실행됩니다.)

The Compact Ledger

Compact에서 contract의 공개 상태는 ledger 선언으로 설정됩니다. 컴파일러는 이를 모두 수집하여 ledger 타입의 형태로 DApp에 노출합니다:

export type Ledger = {
readonly state: State;
readonly message: { is_some: boolean, value: string };
readonly instance: bigint;
readonly poster: Uint8Array;
}

모든 ledger 필드에 대한 읽기 전용 속성이 있습니다. TypeScript에서 읽기 전용인 이유는, ledger를 업데이트하려면 실제로 체인에 transaction을 제출해야 하기 때문입니다. 그러나 DApp은 공개 ledger 상태의 (스냅샷에서) 자유롭게 읽을 수 있습니다.

앞서 State가 이 API에 나타나는 이유는 Compact enum 타입 State를 exported했기 때문이라고 언급했습니다. Compact 표준 라이브러리 타입 Maybe는 이 API에 나타나지 않습니다. 대신, ledger 필드 message는 기본 TypeScript 타입을 가집니다. 표준 라이브러리의 Maybe 타입을 export하지 않았기 때문입니다. Compact contract의 최상위에서 export { Maybe }로 이를 export할 수 있으며, 그러면 다음과 같이 됩니다:

export type Maybe<a> = { is_some: boolean; value: a };

export type Ledger = {
readonly state: State;
readonly message: Maybe<string>;
readonly instance: bigint;
readonly poster: Uint8Array;
}

공개 ledger 상태의 (읽기 전용 스냅샷을) 제공하는 함수 선언도 있으며, 위에서 선언한 Ledger 타입의 TypeScript 값을 반환합니다:

export declare function ledger(state: __compactRuntime.StateValue): Ledger;

이 함수는 Compact 런타임의 StateValue 타입 값을 받습니다. 이 시리즈의 Part 2에서 이 함수가 witness에 Ledger를 전달하는 데 어떻게 사용되는지 살펴보겠습니다.

Compact Circuits

Contract에는 세 개의 exported circuit이 있었습니다. export함으로써 DApp의 TypeScript 또는 JavaScript 코드에서 호출할 수 있게 되며, contract의 진입점을 형성합니다. TypeScript 선언 파일에서 두 곳에 선언을 볼 수 있습니다:

export type ImpureCircuits<T> = {
post(context: __compactRuntime.CircuitContext<T>, newMessage_0: string): __compactRuntime.CircuitResults<T, []>;
takeDown(context: __compactRuntime.CircuitContext<T>): __compactRuntime.CircuitResults<T, string>;
}

export type PureCircuits = {
publicKey(sk_0: Uint8Array, instance_0: Uint8Array): Uint8Array;
}

export type Circuits<T> = {
post(context: __compactRuntime.CircuitContext<T>, newMessage_0: string): __compactRuntime.CircuitResults<T, []>;
takeDown(context: __compactRuntime.CircuitContext<T>): __compactRuntime.CircuitResults<T, string>;
publicKey(context: __compactRuntime.CircuitContext<T>,
sk_0: Uint8Array,
instance_0: Uint8Array): __compactRuntime.CircuitResults<T, Uint8Array>;
}

posttakeDown circuit은 impure합니다. Compact에서 이는 기본적으로 public state에 접근하거나(읽기만 하더라도) witness를 호출한다는 의미입니다. ImpureCircuits<T> 타입에 선언됩니다. 여기서 제네릭 타입 파라미터 T는 contract의 private state 타입입니다. Compact 컴파일러는 이 타입이 무엇인지 알지 못하며(알 필요도 없습니다); DApp 개발자가 이를 채웁니다.

post circuit의 Compact 시그니처는 circuit post(new_message: Opaque<"string">): []였습니다. 이 circuit의 TypeScript API가 Compact 시그니처에서 예측 가능하게 도출되었음을 알 수 있으며, 몇 가지 차이점이 있습니다.

첫째, circuit은 CircuitContext<T> 타입의 추가 첫 번째 인자를 받습니다. 이것은 Compact 런타임에 선언된 인터페이스입니다. Contract의 온체인 및 private state의 캡슐화, 별도의 Zswap 상태, 그리고 circuit이 실제로 온체인에서 실행되는 경우의 온체인 컨텍스트 표현을 포함합니다(다만 이 JavaScript 코드는 온체인에서 실행되는 것이 아닙니다).

둘째, Compact 타입 Opaque<"string">이 TypeScript 타입 string으로 표현됩니다. Compact의 목표 중 하나는 Compact 타입의 TypeScript 표현이 항상 예측 가능한 것입니다.

셋째, 반환 타입(Compact에서는 [])이 실제로는 CircuitResults<T, []>입니다. 이것은 Compact 런타임에 선언된 또 다른 인터페이스입니다. TypeScript 타입 []의 실제 반환값과 함께, ZK proof를 구성하는 데 필요한 일부 proof 데이터, 그리고 circuit 실행 후의 public 및 private state를 나타내는 새 CircuitContext<T>를 포함합니다.

여기서 takeDown에 대해서는 자세히 다루지 않겠지만, 그 시그니처도 마찬가지로 Compact의 circuit 시그니처에서 예측 가능하게 도출되었음을 확인할 수 있습니다.

헬퍼 circuit publicKeypure합니다. 이는 public state에 접근하거나 witness를 호출하지 않는다는 의미입니다(즉, impure하지 않습니다). PureCircuits 타입에 선언됩니다. Pure circuit은 contract의 인스턴스 없이 실행할 수 있는 것입니다. 구체적으로, ledger 상태에 접근할 필요가 없고 private state에 대한 접근 권한도 없습니다. 여기서 이를 확인할 수 있는데, 추가 첫 번째 CircuitContext 인자가 없고, 반환값이 CircuitResult가 아닌 순수 TypeScript 타입입니다. PureCircuits 타입은 제네릭이 아니며, private state의 타입을 나타내는 타입 파라미터 T가 필요하지 않습니다.

마지막으로, 이 선언들이 Circuits<T> 타입에 반복됩니다. posttakeDown의 선언은 이전과 정확히 동일하지만, publicKey의 선언은 impure circuit의 시그니처를 가집니다. 이는 DApp이 pure인지 아닌지의 세부 사항을 걱정하지 않고 publicKey transaction을 만들 수 있도록 하기 위함입니다.

이 circuit들의 구현은 컴파일러가 생성한 JavaScript contract 코드에 있으며, 이 시리즈의 다음 글에서 살펴보겠습니다.

Compact Witnesses

Contract에는 하나의 witness 선언이 있었으며, 이것도 contract의 TypeScript API에 반영됩니다:

export type Witnesses<T> = {
localSecretKey(context: __compactRuntime.WitnessContext<Ledger, T>): [T, Uint8Array];
}

Witness의 시그니처도 Compact witness 선언에서 예측 가능하게 도출됩니다. WitnessContext<Ledger, T> 타입의 추가 첫 번째 인자가 있습니다. 이 인터페이스는 Compact 런타임에 선언되어 있습니다. 공개 ledger 상태의 스냅샷, T 타입의 contract private state, 그리고 contract 주소를 포함합니다. Witness는 T 타입의 새 private state와 Compact 반환값으로 구성된 튜플(두 요소의 TypeScript 배열)을 반환합니다. Compact 타입 Bytes<32>는 기본 TypeScript 타입 Uint8Array로 표현됩니다.

DApp 구현에서는 contract를 생성할 때 이 시그니처를 가진 witness를 제공해야 합니다.

The Contract Type

마지막으로, contract 타입의 선언이 있습니다:

export declare class Contract<T, W extends Witnesses<T> = Witnesses<T>> {
witnesses: W;
circuits: Circuits<T>;
impureCircuits: ImpureCircuits<T>;
constructor(witnesses: W);
initialState(context: __compactRuntime.ConstructorContext<T>): __compactRuntime.ConstructorResult<T>;
}

export declare const pureCircuits: PureCircuits;

Private state 타입과 witness 타입에 대해 파라미터화되어 있으며, 위에서 본 타입 선언들을 사용하는 witness, circuit, impure circuit, 생성자, 초기 상태를 가지고 있습니다. Pure circuit은 contract의 인스턴스가 필요하지 않다는 사실을 반영하여 (contract 속성이 아닌) 최상위 TypeScript 값입니다.

Contract 자체는 index.cjs의 컴파일러 생성 JavaScript 코드로 구현됩니다. 이 시리즈의 다음 글인 "Compact Deep Dive - Part 2: Circuits and Witnesses"에서 circuit과 witness가 생성된 코드에서 어떻게 구현되는지 살펴보겠습니다.