Compact 심층 분석 파트 2 - Circuit과 Witness의 동작 원리
이 블로그 글은 Midnight 네트워크에서 Compact contract가 어떻게 작동하는지 탐구하는 Compact Deep Dive 시리즈의 일부입니다. 각 글은 서로 다른 기술적 주제에 초점을 맞추며 독립적으로 읽을 수 있지만, 전체를 함께 읽으면 Compact이 실제로 어떻게 작동하는지 더 완전한 그림을 얻을 수 있습니다. Compact Deep Dive - Part 1에서는 Compact 컴파일러가 생성한 contract 구현의 TypeScript API를 살펴보았습니다. 아직 읽지 않으셨다면, 먼저 읽어보시기를 권장합니다. 이 글에서는 Compact 컴파일러가 생성한 JavaScript 코드에서 circuit과 witness가 실제로 어떻게 구현되는지 살펴봅니다.
Part 1에서는 Bulletin Board 튜토리얼 contract를 예시로 사용했습니다. Compact 컴파일러로 컴파일하고 컴파일러가 생성한 파일을 살펴보기 시작했습니다. Compact 툴체인 버전 0.24.0을 사용했습니다. Part 1의 주의 사항을 상기하세요. 생성된 코드는 플랫폼의 구현 세부 사항입니다. 우리는 이를 자유롭게 변경하므로, 다른 버전의 컴파일러를 사용하면 다를 수 있습니다.
Circuits
Bulletin board contract의 post circuit을 살펴보았습니다.
컴파일러는 circuit에 대한 선언을 포함하는 TypeScript 선언 파일을 생성했습니다:
post(context: __compactRuntime.CircuitContext<T>, newMessage_0: string): __compactRuntime.CircuitResults<T, []>;
CircuitContext와 CircuitResults 타입은 컴파일러가 사용하는 Compact 런타임에서 가져온 것임을 기억하세요.
index.cjs 파일에서 컴파일러가 이 circuit의 JavaScript 구현을 생성했습니다.
이것은 contract의 Compact 소스 코드를 컴파일할 때 compact 명령줄에서 지정한 컴파일러 출력 디렉토리의 contract 하위 디렉토리에 있습니다.
구현은 Contract 클래스의 생성자에서 circuits 객체의 속성으로 설치됩니다.
전체 코드는 다음과 같습니다(이 섹션의 나머지 부분에서 자세히 살펴보겠습니다):
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];
if (!(typeof(contextOrig_0) === 'object' && contextOrig_0.originalState != undefined && contextOrig_0.transactionContext != undefined))
__compactRuntime.type_error('post',
'argument 1 (as invoked from Typescript)',
'bboard.compact line 26 char 1',
'CircuitContext',
contextOrig_0)
const context = { ...contextOrig_0 };
const partialProofData = {
input: {
value: _descriptor_4.toValue(newMessage_0),
alignment: _descriptor_4.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 };
},
Runtime Type Checks
이 구현의 첫 번째 부분은 컴파일러가 모든 circuit에 대해 생성하는 "보일러플레이트" 코드입니다. 모든 circuit은 기본적으로 동일한 코드를 가지며, 인자의 수와 이름, 파일명, 소스 코드 위치에 따라 약간만 다릅니다. 먼저 이 코드에 집중해 보겠습니다:
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];
if (!(typeof(contextOrig_0) === 'object' && contextOrig_0.originalState != undefined && contextOrig_0.transactionContext != undefined))
__compactRuntime.type_error('post',
'argument 1 (as invoked from Typescript)',
'bboard.compact line 26 char 1',
'CircuitContext',
contextOrig_0)
const context = { ...contextOrig_0 };
여기에 런타임 타입 검사가 몇 가지 있습니다.
먼저, 전달된 실제 인자 수가 기대하는 수와 일치하는지 확인합니다.
이 경우 두 개입니다(두 번째는 circuit의 파라미터 newMessage이고 첫 번째는 컴파일러가 삽입한 CircuitContext 파라미터입니다).
일치하지 않으면 예외를 던집니다.
이것이 Compact과 TypeScript의 차이점 중 하나입니다. TypeScript 컴파일러가 생성하는 JavaScript 코드는 호출 코드도 타입 검사를 통과했다고 가정하므로 런타임에 인자 개수나 타입을 검사하지 않습니다. 하지만 contract의 정확성이 여기에 달려 있으므로, Compact에서는 그런 가정을 하지 않습니다. 대신 생성된 JavaScript 코드가 런타임에 직접 검사를 수행합니다.
const 바인딩을 사용하여 첫 번째 인자에 contextOrig(항상)을 기반으로 한 이름을 부여하고, 두 번째 인자에는 Compact 소스 코드에서 사용한 이름을 기반으로 한 이름을 부여합니다.
변수명에 추가된 _0 같은 접미사는 컴파일러가 항상 고유한 이름을 생성하는 방식입니다.
그런 다음 첫 번째 인자가 Compact 런타임에 정의된 CircuitContext 인터페이스를 실제로 만족하는지 런타임 타입 검사를 합니다.
마지막으로, 원본 CircuitContext를 복사하여 context라는 이름을 붙입니다.
전달받은 원본을 변경하지 않고 복사본을 변경할 수 있도록 하기 위해서입니다.
Proof Data
post transaction의 큰 그림은 witness가 제공하는 private state에 대한 전체 접근 권한으로 circuit의 JavaScript 구현을 실행하는 것입니다.
그런 다음 proof 서버에 circuit이 예상대로 실행되었다는 zero-knowledge(ZK) proof를 생성하도록 요청합니다.
구체적으로, 관찰된 온체인 동작을 생성하는 데 필요한 private 데이터를 알고 있음을 해당 데이터를 공개하지 않고 증명합니다.
이를 위해 circuit 실행에 대한 일부 정보를 "proof 데이터"에 수집해야 합니다. 다음으로 해당 데이터를 초기화합니다:
const partialProofData = {
input: {
value: _descriptor_4.toValue(newMessage_0),
alignment: _descriptor_4.alignment()
},
output: undefined,
publicTranscript: [],
privateTranscriptOutputs: []
};
이것은 Compact 런타임의 TypeScript 인터페이스 ProofData를 만족하는 JavaScript 값입니다.
정의를 살펴보겠습니다. Compact 컴파일러 버전 0.24.0이 사용하는 Compact 런타임의 정의는 다음과 같습니다:
interface ProofData {
input: AlignedValue;
output: AlignedValue;
privateTranscriptOutputs: AlignedValue[];
publicTranscript: Op<AlignedValue>[];
}
partialProofData라고 부르는 이유는 필요한 proof 데이터를 전부 포함하지 않을 수 있기 때문입니다.
오프체인에서 circuit의 JavaScript 코드를 실행할 때 일부 조건 분기를 건너뛸 수 있습니다.
이 경우 실행되지 않은 분기를 채울 '더미' 데이터가 proof에 필요합니다.
이에 대해서는 시리즈 후반에 더 자세히 살펴보겠습니다.
초기값은 앞서 본 보일러플레이트와 비슷합니다.
input, output, publicTranscript 속성은 기본 초기값을 가집니다.
input의 초기값은 Compact에서 circuit의 파라미터 수와 타입에 따라 달라집니다:
ProofData의 TypeScript 선언에서 input이 AlignedValue 타입임을 알 수 있습니다.
이것은 Compact 런타임의 타입 별칭 AlignedValue입니다.
그리고 해당 TypeScript 선언에서 alignment과 value라는 한 쌍의 속성을 가지고 있음을 알 수 있습니다.
Descriptors
이를 완전히 이해하기 위해 Compact 런타임의 AlignedValue TypeScript 선언을 살펴보겠습니다:
type AlignedValue = {
alignment: Alignment;
value: Value;
};
Alignment은 간략히 넘어가겠지만, Value가 무엇인지 보는 것은 유익합니다:
type Value = Uint8Array[];
따라서 aligned value는 일종의 alignment 태그와 Uint8Array의 배열인 값입니다.
이것이 온체인 런타임에서 Compact 값의 ledger 인코딩입니다.
동일한 값의 JavaScript 인코딩과는 다른 인코딩입니다.
동일한 값에 대해 두 가지 다른 표현이 있습니다.
하나는 네이티브 JavaScript 객체이고,
다른 하나는 온체인 런타임에서 사용되는 바이너리 인코딩입니다.
이 두 표현 사이의 변환을 위해 "descriptor"를 사용합니다.
Descriptor는 Compact 타입의 JavaScript 표현입니다.
더 구체적으로, CompactType 인터페이스를 구현하는 객체입니다.
세 가지 메서드가 있습니다: JavaScript 값을 온체인 값으로 변환하는 toValue, 온체인 값을 JavaScript 값으로 변환하는 fromValue, 그리고 온체인 값의 alignment를 반환하는 alignment입니다.
ProofData의 input(post circuit의 newMessage 인자를 나타냄)에 대한 코드는 다음과 같았습니다:
input: {
value: _descriptor_4.toValue(newMessage_0),
alignment: _descriptor_4.alignment()
},
컴파일러는 생성된 JavaScript 코드에서 사용한 여러 descriptor에 대해 최상위 const 바인딩을 JavaScript에 생성합니다.
index.cjs에서 _descriptor_4를 살펴보면:
const _descriptor_4 = new __compactRuntime.CompactTypeOpaqueString();
Compact 타입 Opaque<"string">에 대한 descriptor 인스턴스입니다.
Compact 런타임은 CompactTypeOpaqueString 같은 모든 Compact 타입에 대한 descriptor 클래스를 정의합니다.
Compact 타입 Opaque<"string">의 JavaScript 표현은 JavaScript 문자열이고, ledger 표현은 JavaScript 문자열의 UTF-8 인코딩으로 구성된 (태그가 지정된) 단일 요소 배열입니다.
Circuit을 실행하면서 구축하는 proof 데이터에는 값의 ledger 표현이 포함 되므로, newMessage 파라미터를 해당 표현으로 변환하기 위해 descriptor의 toValue 메서드를 사용합니다.
Wrapping it Up
마지막으로, 약간의 코드가 더 있습니다:
const result_0 = this.#_post_0(context, partialProofData, newMessage_0);
partialProofData.output = { value: [], alignment: [] };
return { result: result_0, context: context, proofData: partialProofData };
이것은 post circuit의 실제 구현을 포함하는 #_post_0이라는 contract 메서드를 호출합니다.
우리가 살펴본 것(post 메서드)은 이 구현을 감싸는 대부분 보일러플레이트 래퍼입니다.
구현 메서드는 context를 받고 우리가 구성한 proof 데이터 객체와 함께 인자를 전달합니다.
반환 후, proof 데이터의 output 속성을 설정합니다.
설정 방식은 Compact circuit의 반환 타입에 따라 달라집니다.
이 경우 Compact 타입 []이므로 비교적 단순합니다.
같은 contract의 takeDown circuit을 살펴보면, 조금 더 흥미로운 것을 볼 수 있습니다:
partialProofData.output = { value: _descriptor_4.toValue(result_0), alignment: _descriptor_4.alignment() };
_descriptor_4가 Compact 타입 Opaque<"string">에 대한 것이었고, proof 데이터에는 ledger 값이 있다는 것을 기억하세요.
JavaScript의 circuit 구현은 JavaScript 값을 반환하므로, toValue와 alignment를 사용하여 ledger 표현으로 인코딩해야 합니다.
마지막으로, circuit 호출의 결과를 반환합니다.
이것은 CircuitResults<T, []>였다는 것을 기억하세요(여기서 T는 contract의 private state 타입입니다).
해당 인터페이스는 Compact 런타임에 있으며 다음과 같습니다:
interface CircuitResults<T, U> {
context: CircuitContext<T>;
proofData: ProofData;
result: U;
}
실제 circuit 구현 #_post_0에 대해서는 이 시리즈의 다음 글에서 더 자세히 살펴보겠습니다.
What are Wrappers For?
구현을 이런 방식으로 래핑하는 이유가 몇 가지 있습니다.
첫째, 앞서 살펴본 보일러플레이트 코드 대부분은 DApp의 JavaScript 코드에서 circuit을 호출할 때 사용됩니다.
안전을 위해 추가 런타임 검사가 필요합니다.
반면, 다른 Compact circuit에서 호출할 때는 Compact 타입 시스템이 안전성을 보장하므로 이런 검사가 필요 없습니다.
따라서 구현 함수(예: #_post_0)를 직접 호출할 수 있습니다.
둘째, Compact circuit을 다른 circuit에서 호출하면 같은 transaction의 일부로 처리됩니다.
이 경우 새로운 ProofData 객체를 만들 필요도, 결과를 CircuitResults로 감쌀 필요도 없습니다.
DApp의 JavaScript 코드에서 들어오는 최상위 circuit 호출에서만 이 작업을 수행하면 됩니다.
Witnesses
Witness의 구현을 간략히 살펴보겠습니다.
Contract에는 하나가 있으며, contract를 생성할 때 전달해야 합니다.
Contract 클래스의 생성자에는 이를 위한 런타임 타입 검사 코드가 있습니다:
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;
아마 더 흥미로운 것은 witness도 래핑된다는 점입니다. Contract에는 각 witness에 대한 메서드가 있습니다:
#_localSecretKey_0(context, partialProofData) {
const witnessContext_0 = __compactRuntime.witnessContext(ledger(context.transactionContext.state), context.currentPrivateState, context.transactionContext.address);
const [nextPrivateState_0, result_0] = this.witnesses.localSecretKey(witnessContext_0);
context.currentPrivateState = nextPrivateState_0;
if (!(result_0.buffer instanceof ArrayBuffer && result_0.BYTES_PER_ELEMENT === 1 && result_0.length === 32))
__compactRuntime.type_error('localSecretKey',
'return value',
'bboard.compact line 24 char 1',
'Bytes<32>',
result_0)
partialProofData.privateTranscriptOutputs.push({
value: _descriptor_2.toValue(result_0),
alignment: _descriptor_2.alignment()
});
return result_0;
}
Circuit 래퍼 post는 JavaScript 코드에서 circuit을 호출할 때 사용되었고, Compact 코드에서 호출할 때는 (구현인 #_post_0을 직접 호출하여) 우회되었습니다.
Witness의 경우 상황이 반대입니다.
JavaScript 코드에서 witness(예: witnesses.localSecretKey)를 몇 번이나 호출하든 상관없고 추적하지도 않습니다.
하지만 Compact circuit에서 호출하면 이를 감지해야 하므로, 래퍼(예: localSecretKey_0)를 거쳐 호출합니다.
Witness 구현은 WitnessContext 인자가 전달될 것으로 기대하므로, 여기서 전달할 하나를 구성합니다.
Ledger, 현재 private state, contract 주소를 포함합니다.
이 시리즈의 Part 1에서 살펴본 컴파일러 생성 ledger 함수를 사용하여 공개 ledger 상태의 JavaScript 표현을 가져옵니다.
그런 다음 실제로 witness 구현을 호출하여 결과와 새 private state를 가져옵니다. Context를 변경하여 private state를 업데이트합니다(circuit 구현을 호출하기 전에 원본 context를 복사했으므로 복사본을 변경하는 것이 안전합니다).
다음으로 witness 반환값에 대한 런타임 타입 검사를 합니다. Circuit으로 들어오는 JavaScript 인자를 검사하는 것과 같은 이유입니다. Witness 구현을 직접 제어하지 않으므로, 반환값이 circuit이 기대하는 타입인지 확인해야 합니다. Compact의 타입 안전성은 이 런타임 검사에 의존합니다.
마지막으로, 가장 바깥쪽 Compact circuit 호출을 실행하면서 구축 중인 proof 데이터의 private transcript에 witness의 반환값을 기록합니다. 이것은 private 데이터이므로 ZK proof를 구성할 때 proof 서버가 이를 알아야 합니다(proof 서버가 private 데이터를 알고 있음을 증명할 수 있도록).
이 시리즈의 다음 글인 "Compact Deep Dive Part 3: The On-Chain Runtime"에서는 bulletin board contract의 post circuit의 실제 구현을 더 자세히 살펴보고 온체인 런타임을 어떻게 사용하는지 알아보겠습니다.