Skip to main content

8 posts tagged with "compact"

View All Tags

Midnight MCP - AI를 활용한 Compact 스마트 컨트랙트 개발

· 6 min read
Idris Olubisi
Developer Educator

Claude, GitHub Copilot, Cursor 같은 AI 코딩 어시스턴트는 개발자의 코드 작성 방식을 크게 바꿨습니다. 하지만 근본적인 한계가 있습니다. 훈련 데이터에 있는 것만 안다는 점입니다.

Midnight의 스마트 컨트랙트 언어인 Compact은 훈련 데이터에 포함되어 있지 않습니다. AI 어시스턴트에게 Compact 컨트랙트를 작성해 달라고 하면 할루시네이션이 발생합니다. 존재하지 않는 문법을 지어내고, 정의된 적 없는 함수를 참조하며, 컴파일이 안 되는 코드를 생성합니다.

Midnight MCP은 이 문제를 해결합니다.

What is MCP?

Model Context Protocol(MCP)은 AI 어시스턴트가 외부 도구와 데이터 소스에 접근할 수 있게 해주는 개방형 표준입니다. 훈련 데이터에만 의존하지 않고, 실시간 문서 조회, 코드베이스 검색, API 호출이 가능해집니다.

Midnight MCP은 Midnight 개발을 위해 특별히 제작된 MCP 서버입니다. AI 어시스턴트에게 다음을 제공합니다:

  • 102개 Midnight 저장소의 인덱싱된 지식
  • 코드를 보여주기 전 실제 컴파일러 검증
  • 문서와 예제 전반에 걸친 시맨틱 검색
  • Compact의 버전 인식 문법 참조

Claude에게 Compact 컨트랙트를 작성해 달라고 하면, Midnight MCP에 올바른 문법을 쿼리하고, 코드를 생성하고, 실제 컴파일러로 검증한 뒤, 작동하는 코드만 보여줍니다.

The problem with AI-generated Compact code

다음 프롬프트를 생각해 보세요:

"Write a simple counter contract in Compact"

Midnight MCP 없이 AI 어시스턴트는 다음과 같이 생성할 수 있습니다:

contract Counter {
state count: Int = 0;

function increment(): Void {
count = count + 1;
}
}

그럴듯해 보이지만 완전히 틀린 코드입니다:

  • Compact은 상태에 state가 아닌 ledger를 사용합니다.
  • Compact에 Int 타입은 없습니다. Uint<32>, Field 등 특정 타입을 사용합니다.
  • Void는 존재하지 않습니다. Compact은 유닛 타입으로 []를 사용합니다.
  • 상태 변경에는 위트니스 함수가 필요하며, 직접 할당이 아닙니다.

AI가 Solidity와 비슷하지만 Compact이 아닌 언어를 지어낸 것입니다. Compact에 익숙하지 않은 개발자라면 처음부터 유효하지 않았던 코드를 디버깅하느라 몇 시간을 낭비할 수 있습니다.

Install Midnight MCP

Midnight MCP은 API 키 없이 60초 만에 설치할 수 있습니다. 사용 중인 도구에 맞는 설정을 추가하세요.

Claude Desktop

macOS에서 ~/Library/Application Support/Claude/claude_desktop_config.json을 편집하세요. Midnight MCP을 활성화하기 위해 다음 설정을 추가하세요:

{
"mcpServers": {
"midnight": {
"command": "npx",
"args": ["-y", "midnight-mcp@latest"]
}
}
}

Cursor

프로젝트 루트에 .cursor/mcp.json을 생성하거나 편집하세요. macOS/Linux에서 ~/.cursor/mcp.json으로, Windows에서 %USERPROFILE%\.cursor\mcp.json으로 전역 설정도 가능합니다. 다음 설정을 추가하세요:

{
"mcpServers": {
"midnight": {
"command": "npx",
"args": ["-y", "midnight-mcp@latest"]
}
}
}

VS Code with GitHub Copilot

프로젝트에 .vscode/mcp.json을 생성하거나 편집하세요. Copilot을 Midnight MCP에 연결하기 위해 다음 설정을 추가하세요:

{
"servers": {
"midnight": {
"command": "npx",
"args": ["-y", "midnight-mcp@latest"]
}
}
}

설정을 추가한 후 AI 어시스턴트를 재시작하세요. 이제 29개의 Midnight 전용 도구에 접근할 수 있습니다.

How Midnight MCP works

Midnight MCP은 AI 어시스턴트를 Midnight 생태계에 연결하는 로컬 서버로 동작합니다.

Midnight MCP Architecture

Compact 컨트랙트를 요청하면:

  1. AI 어시스턴트가 midnight-get-latest-syntax를 호출하여 현재 Compact 문법을 가져옵니다.
  2. 올바른 패턴을 사용하여 코드를 생성합니다.
  3. midnight-compile-contract를 호출하여 실제 컴파일러로 검증합니다.
  4. 컴파일이 실패하면 오류를 읽고, 코드를 수정하고, 재시도합니다.
  5. 검증된 작동 코드를 받게 됩니다.

이 컴파일-검증-수정 루프는 자동으로 이루어집니다. 깨진 중간 버전은 보이지 않습니다.

Real compiler integration

대부분의 코드 생성 도구는 패턴 매칭에 의존하고 결과가 맞기를 바랄 뿐입니다. Midnight MCP은 호스팅된 실제 Compact 컴파일러로 코드를 검증합니다.

컴파일러는 정적 분석으로는 잡을 수 없는 오류를 포착합니다:

  • 타입 불일치: Uint<64>가 필요한 곳에 Field를 사용하는 경우
  • 봉인 필드 위반: 봉인된 상태에 잘못 접근하는 경우
  • 공개 규칙 오류: 프라이버시 어노테이션이 누락되거나 잘못된 경우
  • 미정의 식별자: 정의되지 않은 변수나 타입을 참조하는 경우

컴파일러가 오류를 반환하면 정확한 줄 번호와 열이 포함됩니다:

success: false
message: "Line 12:8 - unbound identifier 'totalSupply'"
errorDetails:
line: 12
column: 8
errorType: error

AI 어시스턴트는 이 정보를 사용하여 코드를 수정하고 재시도합니다.

Graceful fallback

호스팅된 컴파일러를 사용할 수 없는 경우, Midnight MCP은 정적 분석으로 대체합니다. 응답에 어떤 검증 방식이 사용되었는지 표시됩니다:

validationType: "compiler"           # 실제 컴파일러 검증
validationType: "static-analysis-fallback" # 컴파일러 사용 불가

어떤 상황에서든 검증 결과를 받을 수 있으며, 도구가 조용히 실패하는 일은 없습니다.

Semantic search across 102 repositories

현재 Midnight MCP은 Midnight 생태계의 아카이브되지 않은 저장소를 모두 인덱싱합니다:

  • midnightntwrk의 88개 저장소 전체.
  • OpenZeppelin 컨트랙트와 해커톤 수상작을 포함한 14개의 커뮤니티 및 파트너 저장소.

키워드가 아닌 시맨틱 검색 방식입니다. 예를 들어 "find code that handles shielded transactions" 같은 프롬프트는 해당 단어가 코드에 정확히 나타나지 않더라도 관련 결과를 반환합니다.

Prompt: "How do I implement a token with transfer limits?"

midnight-search-compact returns:
- midnight-examples의 토큰 컨트랙트 예제
- 커뮤니티 저장소의 속도 제한 패턴
- 관련 문서 섹션

29 tools for Midnight development

Midnight MCP은 기능별로 정리된 29개의 도구를 제공합니다.

Search tools

Midnight 생태계 전반에서 코드, 문서, 예제를 찾는 데 사용하세요.

ToolPurpose
midnight-search-compact인덱싱된 저장소 전반에서 Compact 언어 코드 검색
midnight-search-docs공식 Midnight 문서 검색
midnight-search-typescriptTypeScript SDK 구현 검색
midnight-fetch-docsdocs.midnight.network에서 실시간 문서 가져오기

Analysis tools

Compact 컨트랙트를 검증, 분석, 리뷰하는 데 사용하세요.

ToolPurpose
midnight-compile-contract실제 Compact 컴파일러로 코드 검증
midnight-analyze-contract15가지 정적 보안 검사 실행
midnight-review-contractAI 기반 보안 리뷰
midnight-extract-contract-structure컨트랙트 구조 및 내보내기 파싱

Generation tools

새 컨트랙트와 문서를 생성하는 데 사용하세요.

ToolPurpose
midnight-generate-contract자연어 설명에서 컨트랙트 생성
midnight-document-contractMarkdown 또는 JSDoc 형식으로 문서 생성

Repository tools

Midnight 저장소의 파일과 문법 참조에 접근하는 데 사용하세요.

ToolPurpose
midnight-get-file인덱싱된 Midnight 저장소에서 파일 가져오기
midnight-get-file-at-version특정 버전의 파일 내용 가져오기
midnight-compare-syntaxCompact 버전 간 문법 비교
midnight-get-latest-syntax현재 Compact 문법 참조
midnight-get-repo-context코딩 시작에 필요한 모든 것 (복합 도구)
midnight-list-examples사용 가능한 예제 컨트랙트 목록

Version management tools

업그레이드를 관리하고 Compact 버전 간 변경 사항을 추적하는 데 사용하세요.

ToolPurpose
midnight-upgrade-check전체 업그레이드 분석 (복합 도구)
midnight-check-breaking-changes버전 간 호환성 깨짐 변경 식별
midnight-get-migration-guide단계별 마이그레이션 안내

Resources and prompts

도구 외에도 Midnight MCP은 9개의 내장 리소스와 5개의 대화형 프롬프트를 제공합니다.

_리소스_는 문법과 예제에 빠르게 접근할 수 있는 항상 사용 가능한 참조입니다:

midnight://syntax/latest      현재 Compact 문법
midnight://examples/counter 카운터 컨트랙트 예제
midnight://examples/token 토큰 컨트랙트 예제
midnight://docs/compact Compact 언어 참조

_프롬프트_는 특정 워크플로우를 안내하는 일반 작업용 템플릿입니다:

create-compact-contract      새 컨트랙트 시작
debug-compact-error 컴파일 오류 수정
security-review 전체 보안 감사
compare-compact-versions 마이그레이션 지원

Architecture

Midnight MCP은 신뢰성을 위해 설계되었습니다:

  • 토큰 효율성: 기본적으로 YAML 출력 (JSON 대비 20-30% 적은 토큰)
  • 복합 도구: 여러 작업을 결합하는 단일 호출
  • 우아한 성능 저하: 서비스를 사용할 수 없을 때 캐시 데이터로 대체
  • 진행 알림: 장시간 작업 중 실시간 업데이트

코드베이스는 10개 테스트 스위트에 걸친 206개 테스트로 완전히 테스트되어 있습니다.

What's next

Midnight MCP은 오픈소스이며 활발히 개발 중입니다. 로드맵은 다음과 같습니다:

  • 컴파일러 결과에서 전체 ZK 서킷 출력 파싱.
  • AI 채팅에서 직접 컨트랙트 배포.
  • 자동 프루버 코드 생성을 위한 TypeScript SDK 통합.
  • 잔액 조회 및 트랜잭션 제출을 위한 로컬 devnet 상호작용.

Learn more

소스 코드를 탐색하고 기여하세요:

-> GitHub repository

-> npm package

-> API documentation

Midnight MCP은 커뮤니티 프로젝트입니다. 기여, 이슈 등록, 기능 요청을 환영합니다.

Compact 심층 분석 파트 3 - 온체인 런타임의 동작 원리

· 13 min read
Kevin Millikin
Language Design Manager

이 블로그 글은 Compact Deep Dive 시리즈의 세 번째 파트로, Compact 컨트랙트가 Midnight 네트워크에서 어떻게 동작하는지를 다룹니다. 각 글은 서로 다른 기술 주제를 다루며 독립적으로 읽을 수 있지만, 함께 읽으면 Compact의 실제 동작 방식을 더 완전히 이해할 수 있습니다. 처음 두 파트는 아래에서 확인할 수 있습니다:

이 글에서는 온체인 런타임과 DApp에서 공개 원장 상태 업데이트가 어떻게 이루어지는지 살펴봅니다.

The Bulletin Board's post Circuit

이전 파트에서는 Bulletin Board 예제 DApp을 통해 컨트랙트 구조와 서킷 및 위트니스 구현을 살펴보았습니다. 컨트랙트의 내보내기된 서킷(예: Bulletin Board 예제의 post)에는 실제 구현을 호출하는 외부 "래퍼"가 있다는 것을 확인했습니다. 이제 그 실제 구현을 살펴보겠습니다.

post의 Compact 구현을 다시 떠올려 보겠습니다:

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;
}

이 서킷은 컨트랙트의 공개 원장 상태에서 state 필드를 읽고 게시판이 비어 있는지 확인합니다. 그런 다음 위트니스 localSecretKey를 호출하고 반환 값을 사용하여 게시물 작성자에 대한 커밋먼트인 poster를 생성합니다. 마지막으로 세 개의 원장 필드(poster, message, state)를 업데이트합니다.

컨트랙트는 Compact 컴파일러 버전 0.25.0으로 컴파일했습니다. 이 시리즈의 파트 1, 2에서 사용한 것과는 다른 버전입니다. 다른 버전을 사용하면 구현 세부 사항이 달라질 수 있습니다. post의 구현은 _post_0이라는 JavaScript 함수에 있습니다. 이 함수의 모든 줄에서 Compact의 동작 방식에 대해 새로운 점을 배울 수 있으므로, 한 줄씩 분석해 보겠습니다. 이 함수의 첫 줄은 Compact 서킷의 첫 줄에 해당합니다:

_post_0(context, partialProofData, newMessage_0) {
__compactRuntime.assert(
_descriptor_0.fromValue(
Contract._query(
context,
partialProofData,
[
{ dup: { n: 0 } },
{ idx: { cached: false,
pushPath: false,
path: [
{ tag: 'value',
value: { value: _descriptor_9.toValue(0n),
alignment: _descriptor_9.alignment() } }] } },
{ popeq: { cached: false,
result: undefined } }]).value)
===
0,
'Attempted to post to an occupied board');

(여기서와 이후에서 JavaScript 코드를 화면에 더 잘 맞도록 재포맷했습니다. 변경된 것은 들여쓰기뿐입니다.) 이 JavaScript 줄을 안쪽부터 바깥쪽으로 읽어보겠습니다. Compact post 서킷에서 가장 먼저 평가되는 부분식은 단순히 state로, 원장의 state 필드를 읽는 것입니다. 이것이 JavaScript 구현에서도 가장 먼저 평가되어야 합니다. 이는 Contract 클래스의 컴파일러 생성 정적 메서드 _query 호출로 구현됩니다. (Contract._query는 이 시리즈의 다음 글에서 자세히 살펴보겠습니다.)

_post_0이 DApp에서 실행될 때 어떤 일이 일어나는지 떠올려 봅시다. JavaScript 코드는 사용자 머신에서 로컬로 실행되며, 위트니스가 사용하는 개인 데이터에 완전히 접근할 수 있습니다. 코드 실행 후, 프루프 서버가 해당 코드가 실제로 실행되었음을 증명하는 영지식(ZK) 증명을 생성합니다. 이후 트랜잭션이 Midnight 체인에 제출됩니다. 체인이 증명을 검증하면, 서킷 구현에서 지정한 대로 공개 원장 상태가 업데이트됩니다.

Midnight 노드는 공개 원장 상태 업데이트를 수행하기 위해 스택 기반 가상 머신인 Impact VM을 사용합니다. 이 머신은 Impact라는 바이트코드 언어로 작성된 프로그램을 실행합니다. 트랜잭션을 제출하기 전에 DApp에서 공개 상태의 로컬 복사본을 업데이트할 때도 동일한 Impact VM을 사용합니다.

The On-Chain Runtime and the Impact VM

Compact 런타임 패키지에는 온체인 런타임의 내장 버전이 포함되어 있습니다. Midnight 네트워크 노드에서 원장 구현에 사용되는 것과 정확히 동일한 Rust 코드입니다. Compact 런타임용으로는 WebAssembly로 컴파일되어 온체인 런타임 패키지로 가져옵니다. 이 패키지를 DApp에서 직접 사용할 수 있으며, 상당 부분은 상위 수준의 Compact 런타임에서 재내보내기됩니다. 경우에 따라 Compact 런타임은 상위 수준 API로 래핑한 버전을 제공합니다 (원장의 네이티브 바이너리 표현 대신 JavaScript 표현을 사용).

note

DApp에서 온체인 런타임 패키지를 직접 사용하는 경우, 공개 상태의 스냅샷으로 작업하고 있다는 점을 반드시 기억하세요. 이 스냅샷은 Midnight 인덱서에서 가져온 것이지만, DApp이 실행되는 동안 체인 자체는 동일한 컨트랙트에 대한 다른 트랜잭션으로 진행되고 있습니다. 이것이 DApp이 "탈중앙화"되어 있다는 것의 일부입니다.

Impact VM은 공개 원장 상태 업데이트를 수행합니다. DApp의 복사본에서는 즉시 수행되고, 이후 트랜잭션이 실행될 때 온체인에서 수행됩니다. 동일한 VM을 사용하는 이유는 다음과 같습니다:

  • 트랜잭션 의미론이 동일합니다.
  • 구현, 업데이트, 유지보수를 한 번만 하면 되어 엔지니어링 비용을 절약합니다.
  • 트랜잭션의 수수료를 계산하려면 Impact 프로그램이 필요합니다.
  • ZK 증명은 이 특정 Impact 프로그램에 대한 것입니다.

Impact VM은 스택 기반 머신입니다. 트랜잭션은 원장의 바이너리 표현으로 인코딩된 값의 스택에서 동작합니다. 트랜잭션은 항상 스택에 세 개의 값으로 시작합니다:

  1. 첫 번째(스택 맨 아래), 트랜잭션의 컨텍스트 객체.
  2. 두 번째, 트랜잭션이 수행한 작업을 수집하는 이펙트 객체.
  3. 세 번째, 컨트랙트의 공개 상태.

트랜잭션이 완료되면 이 세 값이 같은 순서로 스택에 남습니다. VM 스택의 값은 불변입니다. 예를 들어, 상태 업데이트는 스택에서 세 번째에 있는 기존 상태 값을 변경하는 것이 아니라 새 값으로 교체하여 수행됩니다.

Contract._query의 세 번째 인수는 Impact VM 명령어의 JavaScript 표현인 Op 배열입니다. Op은 온체인 런타임에 정의된 타입입니다. 트랜잭션은 이 명령어의 바이너리 인코딩을 사용합니다. 명령어 배열은 부분적인 Impact 프로그램입니다. 전체 프로그램은 일반적으로 여러 번의 Contract._query 호출을 통해 구성됩니다.

Ledger Read Operations

위에서 본 state 필드의 원장 읽기에 대한 Impact 코드는 다음과 같았습니다:

[
{ dup: { n: 0 } },
{ idx: { cached: false,
pushPath: false,
path: [
{ tag: 'value',
value: { value: _descriptor_9.toValue(0n),
alignment: _descriptor_9.alignment() } }] } },
{ popeq: { cached: false,
result: undefined } }]

첫 번째 명령어는 dup 0입니다. 명령어는 Impact VM 스택에서 입력을 찾습니다. dup 0의 0처럼 명령어의 일부로 인코딩된 피연산자를 가질 수도 있습니다.

dup N 명령어는 스택의 값을 복제합니다. 값은 스택 맨 위에서 N 요소 아래에 있으므로, N=0은 스택 맨 위입니다. 이것이 post 트랜잭션의 첫 번째 Impact 명령어이므로, 스택 맨 위는 공개 원장 상태 값이 됩니다.

dup 0 전에 Impact VM의 스택은 스택 바닥부터 위쪽 순서로 <context effects ledger0>이었습니다. ledger0은 이 트랜잭션의 초기 원장 상태입니다. dup 0 후에는 원장 상태의 복사본 두 개가 스택 위에 있는 <context effects ledger0 ledger0>이 됩니다. 이를 통해 값을 소비하는 후속 명령어가 공개 원장 상태를 잃지 않고 실행할 수 있습니다.

두 번째 명령어는 idx [0]입니다. 이 명령어는 스택 맨 위의 값에 인덱싱하여 그 일부를 추출합니다. 스택 맨 위의 값을 제거하고 추출된 부분으로 교체합니다. 명령어의 피연산자는 경로로, 원장으로 인코딩된 값(또는 특수 태그 stack)의 시퀀스입니다. 두 가지 서로 다른 값 인코딩과 이들 간의 변환을 수행하는 디스크립터는 Part 2에서 설명했습니다. 여기서 명령어는 _descriptor_9를 사용합니다:

const _descriptor_9 = new __compactRuntime.CompactTypeUnsignedInteger(255n, 1);

이것은 Compact 런타임의 CompactTypeUnsignedInteger 인스턴스입니다. 최대값과 바이트 크기를 인수로 받습니다. 따라서 이것은 최대값 255인 1바이트 부호 없는 정수(즉, 단순한 부호 없는 바이트)의 디스크립터입니다. 값 0n은 Compact 컴파일러가 state 필드에 할당한 공개 원장 상태 인덱스입니다.

Impact에는 네 가지 서로 다른 인덱싱 명령어가 있습니다: idx, idxc(캐시됨), idxp(경로 푸시), idxpc(경로 푸시, 캐시됨). JavaScript 명령어 표현에서는 동일한 네 가지 변형을 제공하는 불리언 속성 쌍으로 구분합니다. cached는 접근 중인 값이 동일한 트랜잭션에서 이미 접근(즉, 읽기 또는 쓰기)된 경우 true입니다. 이는 캐시된 접근과 캐시되지 않은 접근에 서로 다른 수수료를 부과하는 데 잠재적으로 사용될 수 있습니다. 여기서는 트랜잭션에서 아직 state 원장 필드에 접근하지 않았습니다. pushPath는 후속 쓰기 작업을 위해 경로를 스택에 남겨두는 변형입니다.

idx [0] 전에 VM의 스택은 <context effects ledger0 ledger0>이었습니다. idx [0] 후에는 <context effects ledger0 state>가 됩니다(ledger0은 전체 공개 원장 상태이고 state는 해당 이름의 필드 값입니다).

세 번째 명령어는 popeq입니다. 이 명령어는 VM 스택 맨 위의 값을 합니다. Impact VM은 두 가지 모드로 실행될 수 있습니다. DApp에서 로컬로 실행될 때는 수집(gathering) 모드로 실행됩니다. 체인에서 컨트랙트의 공개 원장 상태 업데이트를 실행할 때는 검증(verifying) 모드로 실행됩니다. 수집 모드에서는 팝된 값이 수집됩니다(Contract._query에서 이 원장 읽기의 결과로 사용됩니다). 검증 모드에서는 VM이 팝된 값이 명령어의 피연산자와 같은지 확인합니다. 여기서는 수집 모드로 실행 중이므로 명령어의 result 피연산자는 JavaScript의 undefined 값입니다.

popeq 전에 Impact VM의 스택은 <context effects ledger0 state>이었습니다. popeq 후에는 <context effects ledger0>이 됩니다.

이 세 가지 간단한 VM 명령어가 최상위 원장 읽기를 구현합니다.

Assert in Circuits

post 서킷은 게시판의 상태가 비어 있는지를 확인합니다. 이는 위의 JavaScript 코드로 구현됩니다(Impact 명령어는 생략):

__compactRuntime.assert(
_descriptor_0.fromValue(
Contract._query(
context,
partialProofData,
/* Impact code */).value)
===
0,
'Attempted to post to an occupied board');

Contract._query는 원장으로 인코딩된 결과를 가진 객체를 반환합니다. _descriptor_0State 열거형 타입의 디스크립터이므로, _descriptor_0.fromValue를 사용하여 VACANT=0 또는 OCCUPIED=1 열거형 값 중 하나로 변환합니다. 이 값은 JavaScript의 엄격한 동등 연산자를 통해 비어 있는 값(0)과 비교됩니다. 어서션 자체는 Compact 런타임의 assert 함수 호출로 구현됩니다. 조건이 true가 아니면 DApp의 post 트랜잭션은 Midnight 체인에 도달하지 않고 실패합니다.

DApp을 로컬에서 실행할 때 어서션이 성공하면, ZK 증명은 그것이 성공했음을 증명합니다. 온체인에서 증명이 검증되면, Midnight 네트워크는 이 어서션이 참이었음을 알게 됩니다.

온체인 Impact 프로그램은 이 속성(stateState.VACANT이었음)을 직접 확인하지 않습니다. 그러나 popeq 명령어가 검증 모드에서 실행될 때, DApp에서 로컬로 팝된 실제 값을 나타내는 피연산자를 갖게 됩니다. 따라서 구체적으로, DApp이 성공적으로 post 트랜잭션을 구성했다면, 해당 명령어는 popeq 0이 됩니다.

이것은 state 필드가 0이었다고 확인하는 것과 미묘하게 다릅니다. Compact 프로그램에서 assert를 제거하고 원장 필드 읽기만 남겨두면, 온체인에서 실행되는 Impact 프로그램은 DApp이 읽은 실제 값에 따라 popeq 0 또는 popeq 1이 될 수 있습니다.

Circuit and Witness Calls

Compact post 서킷의 두 번째 줄은 다음과 같습니다:

poster = disclose(publicKey(localSecretKey(), instance as Field as Bytes<32>));

이것은 원장의 poster 필드에 대한 쓰기입니다. 쓰기 연산의 우변은 다음 JavaScript 코드로 구현됩니다:

const tmp_0 = this._publicKey_0(
this._localSecretKey_0(context, partialProofData),
__compactRuntime.convert_bigint_to_Uint8Array(
32,
_descriptor_1.fromValue(
Contract._query(
context,
partialProofData,
[
{ dup: { n: 0 } },
{ idx: { cached: false,
pushPath: false,
path: [
{ tag: 'value',
value: { value: _descriptor_9.toValue(2n),
alignment: _descriptor_9.alignment() } }] } },
{ popeq: { cached: true,
result: undefined } }]).value)));

컴파일러는 나중에 참조할 수 있도록 이 값을 tmp_0이라고 명명했습니다.

위트니스 localSecretKey 호출은 컨트랙트의 _localSecretKey_0 메서드 호출로 컴파일되었습니다. Part 2에서 설명한 것처럼, 이 메서드는 DApp이 제공하는 위트니스 구현의 래퍼입니다. 래퍼는 위트니스 반환 값에 대한 타입 검사를 수행하고, 해당 반환 값을 트랜잭션의 비공개 입력 중 하나로 기록합니다.

위의 Impact 코드는 원장의 instance 필드 읽기를 구현합니다. state 읽기 코드와 거의 동일합니다. 차이점은 Compact 컴파일러가 instance에 인덱스 2를 할당했다는 것입니다.

instance 필드는 Counter 원장 타입을 가집니다. Compact에서 읽으면 Uint<64>를 반환합니다. 이 값의 원장 표현은 _descriptor_1을 사용하여 JavaScript 표현(bigint)으로 변환됩니다:

const _descriptor_1 = new __compactRuntime.CompactTypeUnsignedInteger(18446744073709551615n, 8);

여기서 최대값은 부호 없는 64비트 정수의 최대값이고 바이트 크기는 8입니다.

Type Casts in Compact

Compact 코드에는 instance as Field as Bytes<32>라는 타입 캐스트 시퀀스가 있습니다. instanceCounter이고, 원장에서 읽으면 Uint<64> 타입의 Compact 값을 제공합니다. 이것은 Bytes<32>로 직접 캐스트할 수 없으므로 Field를 거치는 중간 캐스트를 사용합니다.

Compact에는 세 가지 종류의 타입 캐스트가 있습니다: 업캐스트, 다운캐스트, 그리고 "크로스 캐스트"입니다. 업캐스트는 하위 타입에서 상위 타입으로의 캐스트입니다. 컴파일러가 인식하는 정적 타입만 변경되며, 런타임에는 아무 효과가 없습니다. 예를 들어, Uint<64>에서 Field로의 캐스트는 업캐스트입니다. 이 캐스트는 정적으로(즉, 컴파일러에 의해) 수행되었지만, 이를 구현하는 JavaScript 코드는 없습니다. 다운캐스트는 상위 타입에서 하위 타입으로의 캐스트입니다. 값의 표현은 보통 변경되지 않지만, 컴파일러가 생성한 JavaScript 코드에서 런타임 검사가 필요합니다. 크로스 캐스트는 서브타이핑 관계에서 관련 없는 타입으로의 캐스트입니다. 예를 들어, FieldBytes<32>로 캐스트하는 것은 크로스 캐스트입니다. 크로스 캐스트는 보통 값의 표현 변경이 필요합니다. Compact 런타임 함수 convert_bigint_to_Uint8Array가 이 경우 표현 변경을 수행합니다.

그런 다음 Compact에서 publicKey 서킷에 대한 호출이 있었습니다. 여기서 호출은 내보내기된 서킷의 publicKey 래퍼가 아니라 JavaScript 구현 메서드 _publicKey_0으로 직접 갑니다. Part 2에서 설명한 것처럼, 서킷 래퍼는 Compact에서 Compact으로 호출할 때 필요하지 않은 일부 런타임 타입 검사를 수행했습니다 (Compact 타입 시스템이 이러한 검사가 필요 없음을 보장합니다). 더 중요한 점은, 서킷 래퍼가 트랜잭션을 위한 새 증명 데이터 객체를 생성했다는 것입니다. publicKey 호출은 DApp이 구성 중인 post 트랜잭션의 일부이므로, 기존 증명 데이터를 재사용해야 합니다.

Compact의 disclose 연산자는 런타임에서 아무것도 하지 않습니다. 위트니스 localSecretKey로부터 계산된 값이 온체인에 노출되었기 때문에 disclose가 필요했습니다. 이것은 Compact 컴파일러에게 이 값의 노출을 허용하라는 지시이지만, 런타임 효과는 없습니다.

Ledger Writes

JavaScript에서 tmp_0이라고 명명된 우변 값은 원장 필드 poster에 기록되어야 합니다. 이를 수행하는 코드는 JavaScript 코드의 다음 줄에 있습니다:

Contract._query(
context,
partialProofData,
[
{ push: { storage: false,
value: __compactRuntime.StateValue.newCell(
{ value: _descriptor_9.toValue(3n),
alignment: _descriptor_9.alignment() }).encode() } },
{ push: { storage: true,
value: __compactRuntime.StateValue.newCell(
{ value: _descriptor_2.toValue(tmp_0),
alignment: _descriptor_2.alignment() }).encode() } },
{ ins: { cached: false, n: 1 } }]);

이것도 부분적인 Impact 프로그램으로 구현됩니다. 위의 Impact 코드는 원장 셀 쓰기를 구현합니다(위에서 본 읽기와 대조적입니다).

첫 번째 명령어는 push 3입니다. Impact의 push 명령어는 pop 명령어의 역입니다. Impact에는 두 가지 변형이 있습니다: pushpushs. JavaScript 명령어 표현에서는 불리언 storage 속성으로 구분됩니다. storage가 false이면 값은 Impact VM의 메모리에만 유지되며 원장에 기록되지 않습니다.

두 번째 명령어는 pushs tmp_0입니다. storage가 true이므로 pushs 명령어입니다(이 값이 원장에 기록됩니다). 명령어의 피연산자는 tmp_0의 실제 Bytes<32> 값입니다. 예상할 수 있듯이, _descriptor_2Bytes<32>의 디스크립터로, JavaScript 값을 원장 값으로 변환하는 데 사용됩니다:

const _descriptor_2 = new __compactRuntime.CompactTypeBytes(32);

이 두 명령어를 실행하기 전에 Impact VM의 스택은 <context effects ledger0>이었습니다. 이 두 명령어를 실행한 후에는 <context effects ledger0 3 tmp_0>이 됩니다.

다음 명령어는 VM 스택의 값에 삽입하는 ins 1입니다. Impact의 ins 명령어는 원장 읽기에서 본 idx 명령어의 역입니다. 삽입할 값은 스택 맨 위에 있습니다(즉, tmp_0Bytes<32> 값). 그 아래에는 N 요소로 구성된 경로가 있으며, Nins 명령어의 피연산자입니다. 이 경우 경로의 길이는 1이므로 [3]이라는 단일 시퀀스입니다. 이것은 Compact 컴파일러가 최상위 원장 필드 poster에 할당한 인덱스입니다.

삽입 명령어는 경로 아래의 값(즉, 공개 원장 상태)을 제거하고, 경로가 가리키는 위치가 새 값으로 업데이트된 새 복사본으로 교체합니다.

ins 1 전에 Impact VM의 스택은 <context effects ledger0 3 tmp_0>이었습니다. ins 1 후에는 <context effects ledger1>이 됩니다. 여기서 ledger1poster에 대한 쓰기를 나타내는 새로운 공개 원장 상태입니다.

_post_0의 나머지에는 두 개의 원장 쓰기와 JavaScript 빈 배열로 표현되는 Compact 빈 튜플의 반환이 있습니다:

const tmp_1 = this._some_0(newMessage_0);
Contract._query(
context,
partialProofData,
[
{ push: { storage: false,
value: __compactRuntime.StateValue.newCell(
{ value: _descriptor_9.toValue(1n),
alignment: _descriptor_9.alignment() }).encode() } },
{ push: { storage: true,
value: __compactRuntime.StateValue.newCell(
{ value: _descriptor_5.toValue(tmp_1),
alignment: _descriptor_5.alignment() }).encode() } },
{ ins: { cached: false, n: 1 } }]);
Contract._query(
context,
partialProofData,
[
{ push: { storage: false,
value: __compactRuntime.StateValue.newCell(
{ value: _descriptor_9.toValue(0n),
alignment: _descriptor_9.alignment() }).encode() } },
{ push: { storage: true,
value: __compactRuntime.StateValue.newCell(
{ value: _descriptor_0.toValue(1),
alignment: _descriptor_0.alignment() }).encode() } },
{ ins: { cached: false, n: 1 } }]);
return [];

위 코드가 어떻게 동작하는지 직접 확인해 보세요.

publicKey

온체인 런타임이 Compact에서 사용되는 또 하나의 방식이 있습니다. publicKey 서킷의 구현(내보내기된 서킷의 래퍼가 아닌 실제 구현)을 살펴보면:

_publicKey_0(sk_0, instance_0) {
return this._persistentHash_0([new Uint8Array([98, 98, 111, 97, 114, 100, 58, 112, 107, 58, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
instance_0,
sk_0]);
}

패딩된 문자열 리터럴이 명시적인 Uint8Array로 컴파일된 것을 볼 수 있습니다. 또한 Compact 표준 라이브러리의 persistentHash 서킷_persistentHash_0 메서드 호출도 볼 수 있습니다.

컴파일러는 이 서킷의 JavaScript 구현을 포함하고 있습니다:

_persistentHash_0(value_0) {
const result_0 = __compactRuntime.persistentHash(_descriptor_7, value_0);
return result_0;
}

이것은 Compact 런타임의 persistentHash 함수 호출입니다. Compact 런타임의 이 함수는 온체인 런타임 자체의 persistentHash에 대한 얇은 래퍼입니다. 차이점은 온체인 런타임 버전은 값의 원장 인코딩에서 작동하고, Compact 런타임 버전은 전달된 디스크립터를 사용하여 관련 변환을 수행한다는 것입니다.

이것이 이 글에서 살펴볼 온체인 런타임의 마지막 기능입니다. DApp과 Midnight 노드가 공유하는 persistentHash 같은 함수의 구현을 포함하고 있습니다.

다음 글에서는 부분적인 Impact 프로그램들이 어떻게 하나로 조합되는지, 그리고 컴파일러가 생성한 JavaScript 코드가 ZK 증명을 위한 공개 입력과 공개 출력을 어떻게 생성하는지 살펴보겠습니다.

Midnight의 FungibleToken 컨트랙트 소개

· 9 min read
Claude Barde
Developer Relations

Introduction to the FungibleToken contract on Midnight

Midnight은 탈중앙화 애플리케이션에 프라이버시를 제공하기 위해 설계된 프라이버시 우선 블록체인입니다. 영지식 증명, 프로그래밍 가능한 데이터 보호, 그리고 개발자 친화적 도구Compact(TypeScript 기반 DSL, 도메인 특화 언어)을 통해 이를 실현합니다.

OpenZeppelin은 Ethereum 생태계에서 실전 검증된 스마트 컨트랙트 라이브러리로 유명하며, 온체인에서 수조 달러 규모의 가치를 보호해 왔습니다. 최근 OpenZeppelin은 Midnight과 파트너십을 맺고 ERC-20과 같은 친숙한 표준을 프라이버시 보존 버전으로 적용하여 Compact 생태계에 유사한 도구를 제공하고 있습니다.

Ethereum 세계에서 ERC-20 표준은 balanceOf, transfer, approve 등의 공개 원장 함수를 가진 대체 가능 토큰을 정의합니다. 트랜잭션 데이터를 투명하게 노출하며 내장된 프라이버시가 없습니다. Midnight의 FungibleToken 컨트랙트는 여기서 영감을 얻었지만, Midnight의 영지식, 선택적 공개 프레임워크 안에서 동작합니다.

대체 가능 토큰(Fungible Token)은 블록체인 생태계의 핵심 요소로, 전통 화폐처럼 서로 교환 가능한 디지털 자산입니다. 거래 촉진, DeFi 프로토콜 운영, 디지털 커뮤니티 내 소유권 표현, 게임 내 경제 구동 등 폭넓게 활용됩니다.

각각 고유한 NFT와 달리, 대체 가능 토큰은 같은 종류끼리 동일한 가치를 지니므로 분할과 교환이 필요한 사용 사례에 적합합니다. 이런 폭넓은 채택은 유동적이고 상호 연결된 디지털 경제에서 대체 가능 토큰이 얼마나 중요한지를 보여줍니다.

이 글에서는 컨트랙트의 핵심 기능을 다룹니다. 원장 상태 변수 관리, 민팅/소각/전송 등 주요 진입점과 서킷, 그리고 UtilsInitializable 모듈의 안전 및 유틸리티 함수를 살펴봅니다. 이 구성 요소들의 관계를 이해하면, FungibleToken 컨트랙트가 대체 가능성, 사용성, 프라이버시를 어떻게 균형 있게 달성하는지 파악할 수 있습니다. Midnight에서 프라이버시 보존 DeFi, 신원 확인, 토큰화 자산을 구현하기 위한 필수 빌딩 블록입니다.


Features of the FungibleToken Contract

Midnight의 FungibleToken 컨트랙트는 원장 상태 변수를 활용하여 잔액, 허용량, 총 공급량, 이름, 심볼, 소수점을 추적합니다. 기능은 Mint, Burn, Transfer, Approve, TransferFrom, Initialize 등의 "서킷"(진입점)을 통해 노출되며, 모두 특정 영지식 검증 전환을 강제하고 토큰 상태의 무결성을 유지합니다.

Ledger State Variables

Compact에서 컨트랙트는 토큰 잔액과 허용량을 저장하는 구조화된 상태를 정의합니다. ERC-20과 유사합니다. _balances 맵은 사용자의 토큰 잔액을 추적하며 전송이 발생할 때 업데이트됩니다. _allowances 맵은 다른 사용자를 대신하여 토큰을 사용할 수 있도록 특정 사용자에게 부여된 권한을 추적합니다:

export ledger _balances: Map<Either<ZswapCoinPublicKey, ContractAddress>, Uint<128>>;
export ledger _allowances: Map<Either<ZswapCoinPublicKey, ContractAddress>, Map<Either<ZswapCoinPublicKey, ContractAddress>, Uint<128>>>;

이 값들은 컨트랙트의 원장에 저장되며, 컨트랙트로 전송된 트랜잭션을 통해 업데이트됩니다.

컨트랙트 배포 시 설정되는 다른 값들도 있습니다:

export ledger _totalSupply: Uint<128>;
export sealed ledger _name: Opaque<"string">;
export sealed ledger _symbol: Opaque<"string">;
export sealed ledger _decimals: Uint<8>;

이 값들은 컨트랙트가 관리하는 토큰에 대한 다양한 정보를 제공합니다. 총 공급량, 이름, 심볼, 소수점(표시용) 등입니다.

Entry Points and Circuits

Compact에서 진입점은 서킷(Solidity 함수와 유사)으로 정의되며, 각각 영지식 검증 전환을 모델링합니다. 서킷 진입점과 서킷의 차이점은 진입점은 트랜잭션을 통해 호출할 수 있지만, 진입점이 아닌 서킷은 내부용이라는 것입니다. 핵심 서킷은 다음과 같습니다:

  • Mint / Burn (새 토큰을 민팅하거나 기존 토큰을 소각).

  • Transfer: 주소 간 토큰 이동.

  • Approve, TransferFrom: 표준 ERC-20 스타일 위임 메커니즘.

  • Initialize: Initializable 모듈을 통한 컨트랙트 설정.

각 서킷은 필요한 제약 조건을 강제합니다. 예를 들어 충분한 잔액 확인, 허용량 차감 관리, 총 공급량 보존 등입니다.

컨트랙트 수명 주기의 다음 단계에서는 컨트랙트의 원장에 저장된 다양한 메타데이터가 안전하게 초기화됩니다.

Initialization and Metadata**

다음 서킷들은 대체 가능 토큰 메타데이터와 사용자 잔액을 위한 필수 설정 및 조회 로직을 정의하며, 올바른 초기화를 강제합니다.

  • initialize(name_, symbol_, decimals_) 일회성 설정입니다. Initializable_initialize()를 호출한 뒤, (공개된) 이름, 심볼, 소수점을 저장합니다. 다른 모든 공개 서킷은 먼저 컨트랙트가 초기화되었는지 확인합니다.

  • name() / symbol() / decimals() / totalSupply() 초기화 여부를 먼저 확인한 뒤 봉인된(읽기 전용) 원장 값을 반환하는 간단한 게터입니다.

  • balanceOf(account) 키가 없는 경우 컨트랙트 실패를 방지하기 위해 0을 반환하는 안전한 맵 조회입니다.


The Transfer Family

FungibleToken 컨트랙트의 전송 서킷은 토큰 이동을 관리합니다. 주요 서킷은 다음과 같습니다: 안전한 사용자 주도 전송을 위한 transfer, 내부 토큰 이동을 위한 _unsafeTransfer, 관리자 전송을 위한 _transfer, 저수준 토큰 이동을 위한 _unsafeUncheckedTransfer, 그리고 모든 토큰 작업의 중앙 회계 함수인 _update.

안전한 변형과 안전하지 않은 변형으로 나뉘는 이유는 현재 컨트랙트 주소로의 전송이 허용되지 않기 때문입니다(컨트랙트 간 상호작용이 지원될 때까지).

"안전한" 서킷은 이 정책을 강제하고, "안전하지 않은" 서킷은 이를 우회할 수 있으며, 주석에 위험하다고 명시적으로 표시되어 있습니다.

  • transfer(to, value) -> Boolean 안전한 사용자 주도 전송: toContractAddress이면 거부합니다. 내부적으로는 검사 후 안전하지 않은 변형으로 전달합니다.

  • _unsafeTransfer(to, value) -> Boolean 소유자가 호출자(left(ownPublicKey()))입니다. 검사 없는 내부 이동 함수를 사용하여 값을 이동한 뒤 true를 반환합니다.

  • _transfer(from, to, value) -> [] 관리자/확장 훅으로, 임의의 from에서 토큰을 이동합니다(반드시 호출자일 필요 없음). "컨트랙트를 to로 사용할 수 없음" 규칙은 여전히 강제되며, 내부적으로 동일한 이동 함수를 사용합니다.

  • _unsafeUncheckedTransfer(from, to, value) -> [] 저수준 이동 함수로, 양쪽이 영/소각 주소가 아닌지 확인한 뒤 실제 회계를 _update에 위임합니다.

  • _update(from, to, value) -> [] 모든 민팅/소각/전송 경로에서 사용되는 중앙 회계 함수입니다. 내부 서킷으로, 트랜잭션을 통해 호출할 수 없습니다.

    • from이 영 주소이면 민팅 서킷이 호출되어 uint128 오버플로우가 없는지 확인하고 _totalSupply를 증가시킵니다.

    • 그렇지 않으면 from 잔액에서 차감합니다(잔액 부족 시 되돌림).

    • to가 영 주소이면 소각 서킷이 호출되어 _totalSupply를 감소시킵니다.

    • 그렇지 않으면 to 잔액에 추가합니다. 이 단일 함수가 모든 가치 이동에 대한 불변성을 보장합니다.

"전송 계열" 서킷은 안전한 토큰 이동을 보장하며, "안전한" 변형은 컨트랙트 주소로의 전송을 차단하고 "안전하지 않은" 변형은 저수준 제어를 제공합니다. 다음으로 허용량이 어떻게 작동하여 위임된 토큰 사용을 가능하게 하는지 살펴보겠습니다.


Allowances (approve / spend / transferFrom)

이 섹션에서는 FungibleToken 컨트랙트의 허용량(allowance) 메커니즘을 다룹니다. 사용자가 다른 주소에 토큰 지출 권한을 위임할 수 있는 서킷들로, 소유자의 개인 키를 직접 노출하지 않고도 대리 전송이 가능합니다.

  • allowance(owner, spender) 중첩된 _allowances 맵을 읽으며, 키가 없을 때 0을 반환합니다(되돌림 없음).

  • approve(spender, value) -> Boolean 소유자가 호출자입니다. _approve(owner, spender, value)로 전달하고 true를 반환합니다.

  • transferFrom(from, to, value) -> Boolean 안전한 위임 전송: "컨트랙트 수신자 불가" 규칙을 강제한 뒤, _unsafeTransferFrom에 위임합니다.

  • _unsafeTransferFrom(from, to, value) -> Boolean 지출자가 호출자입니다. 먼저 _spendAllowance(from, spender, value)를 통해 허용량을 사용한 뒤, _unsafeUncheckedTransfer로 값을 이동합니다. true를 반환합니다.

  • _approve(owner, spender, value) -> [] 소유자와 지출자가 모두 유효한지 확인하고, 필요하면 맵에 소유자 항목을 생성한 뒤 허용량을 기록합니다. (OZ의 ERC-20 패턴인 공개 approve() -> 내부 _approve() 구조를 따릅니다.)

  • _spendAllowance(owner, spender, value) -> [] "무한"이 아닌 한 허용량에서 차감합니다. 구현에서 **MAX_UINT128**을 무한으로 취급합니다: currentAllowance == MAX이면 차감하지 않고, 그렇지 않으면 currentAllowance >= value를 확인한 뒤 currentAllowance - value를 기록합니다. 이는 앱이 MAX를 한 번 설정하면 추가 승인 없이 사용할 수 있는 "무마찰 승인"을 지원하기 때문에 중요합니다.

지금까지 토큰 지출을 다른 주소에 위임하는 허용량 메커니즘을 살펴보았습니다. 다음으로 토큰의 생성(민팅)과 소멸(소각) 방법을 알아보겠습니다.


Minting and Burning

FungibleToken 컨트랙트가 토큰을 생성하고 소멸하는 방법입니다. _mint_burn 함수가 무엇을 하고 주요 회계 시스템과 어떻게 연결되는지 살펴보겠습니다.

  • _mint(account, value) (안전한) -> [] 컨트랙트 주소로의 민팅을 금지합니다(동일한 컨트랙트 간 제한). _unsafeMint로 전달합니다.

  • _unsafeMint(account, value) -> [] 수신자 주소를 검증한 뒤, _update(burnAddress(), account, value)를 호출합니다. 즉, 민팅은 소각/영 주소로부터의 전송으로 모델링됩니다.

  • _burn(account, value) -> [] 발신자 주소를 검증한 뒤, _update(account, burnAddress(), value)를 호출합니다. 즉, 소각은 소각/영 주소로의 전송입니다. 참고: "영/소각" 주소의 실제 개념은 Utils 모듈에서 표준화되어 있습니다. Utils_isKeyOrAddressZeroUtils_isContractAddress 같은 헬퍼도 확인할 수 있습니다.

민팅과 소각도 _update를 통해 라우팅되므로, 총 공급량은 정확히 한 곳에서만 조정되며, 모든 흐름에 동일한 안전 검사가 적용됩니다(민팅 시 uint128 오버플로우 검사 포함).

민팅과 소각 서킷은 _update 함수를 사용함으로써 총 공급량 조정이 항상 일관되고 모든 토큰 흐름에 동일한 안전 검사가 적용되도록 보장합니다. 이제 UtilsInitializable 모듈이 제공하는 추가 안전 및 유틸리티 기능을 살펴보겠습니다.


Safety and Utility Glue (from Utils and Initializable)

UtilsInitializable 모듈이 제공하는 핵심 보호 장치와 유틸리티 함수를 살펴봅니다. 컨트랙트의 무결성을 유지하고 안전한 운영을 보장하는 필수 구성 요소입니다.

  • 초기화 가드: Initializable_initializeInitializable_assertInitialized 함수는 초기화 가드 역할을 합니다. 컨트랙트 상태가 단 한 번만 올바르게 설정되고, 초기화가 완료된 경우에만 이후 작업이 진행되도록 보장합니다. 상태를 읽거나 수정하는 모든 서킷은 assert 함수를 호출하여 초기화 무결성을 검증합니다.

  • 주소 헬퍼:

    • Utils_isContractAddress(either): 사용자 키와 컨트랙트 주소를 구분합니다.

    • Utils_isKeyOrAddressZero(either): _update, _unsafeUncheckedTransfer 등에서 사용되는 영/소각 주소를 감지합니다. 이들은 임시 "컨트랙트 수신자 불가" 정책과 영 주소 검사를 지원합니다.

UtilsInitializable 모듈은 컨트랙트의 올바른 설정과 안전한 운영을 뒷받침합니다. 다음으로 FungibleToken 컨트랙트의 전체 구성 요소가 어떻게 맞물려 동작하는지 살펴보겠습니다.


How the Pieces Fit Together

FungibleToken 컨트랙트의 모든 구성 요소가 어떻게 연결되는지 정리합니다. 직접 전송이든, 위임 전송이든, 민팅이든, 소각이든, 결국 몇 가지 핵심 함수를 거쳐 최종적으로 _update 함수에서 회계 처리됩니다.

  • 사용자 전송: transfer -> (안전 검사) -> _unsafeTransfer -> _unsafeUncheckedTransfer -> _update (잔액/공급량)

  • 위임 전송: transferFrom -> (안전 검사) -> _unsafeTransferFrom -> _spendAllowance -> _unsafeUncheckedTransfer -> _update

  • 민팅/소각: _mint/_unsafeMint 또는 _burn -> _update (한쪽에 영/소각 주소)

사용자 전송, 민팅, 소각 등 모든 토큰 작업이 중앙 _update 함수를 통해 일관된 회계를 유지하는 구조를 살펴보았습니다. 마지막으로 Midnight FungibleToken 컨트랙트의 핵심 사항을 정리합니다.


Conclusion

Midnight의 FungibleToken Compact 컨트랙트는 ERC-20 표준을 프라이버시 관점에서 재설계한 것입니다. 잔액, 전송, 승인 등 익숙한 토큰 인터페이스는 그대로 유지하면서, Compact의 ZK 검증 서킷으로 인코딩하여 비공개이면서도 검증 가능한 실행을 달성합니다. 컨트랙트의 상태와 로직은 설계 단계부터 보호되며, 원시 데이터가 아닌 증명만 블록체인에 기록됩니다.

ERC-20 표준은 디지털 자산의 생성과 관리를 위한 공통 프레임워크를 제공하여 상호운용성을 높이고 탈중앙화 애플리케이션의 성장을 가속했습니다. Midnight의 ERC-20 기반 토큰은 이 검증된 표준을 유지하면서 ZK 프라이버시를 결합하여, 기능성과 기밀성 모두를 필요로 하는 개발자와 사용자에게 친숙하면서도 한 단계 발전된 경험을 제공합니다.

모든 토큰 이동과 잔액이 완전히 투명한 Ethereum의 ERC-20과 달리, Midnight은 선택적 공개를 지원합니다. 사용자와 애플리케이션이 무엇을 공개할지 직접 결정합니다. FungibleToken 컨트랙트는 대체 가능성, 사용성, 프라이버시의 균형을 달성하여, Midnight 기반 프라이버시 보존 DeFi, 신원 확인, 토큰화 자산의 필수 빌딩 블록 역할을 합니다.


코드, 컨트랙트, 문서를 직접 살펴보세요.

난관을 넘어 프레임워크를 만들기까지: Midnight 개발기

· 6 min read
Kaleab Abayneh
Kaleab Abayneh
Guest Contributor

올해 세 번의 해커톤에 참가한 뒤, 무엇을 기대해야 하는지 충분히 알고 있다고 생각했습니다. 그런데 네 번째 해커톤은 달랐습니다. 아이디어, 도전, 빈 터미널 화면이라는 똑같은 시작이었지만, 예상치 못한 방향으로 흘러갔습니다.

얼마 전 소프트웨어 엔지니어 직책을 떠나 영지식 프로토콜을 탐구하기 시작했습니다. 경험이 거의 없는 분야였지만, 반드시 도전해야 한다고 느꼈습니다. 영지식 암호학이 수학에서 가장 우아한 분야 중 하나를 기반으로 한다는 점도 있었지만, 블록체인이나 인터넷에서의 프라이버시는 근본적인 가치이며 결코 뒷전으로 밀려서는 안 된다고 믿었기 때문입니다.

어느 날, 멘토가 African Blockchain Championship을 언급하며 Zero-Knowledge 트랙을 살펴보라고 권했습니다. 그 작은 한마디가 전환점이 되었습니다.

바로 그때 Midnight을 발견했습니다!

Meet Midnight: Privacy on the Blockchain

Midnight은 Input Output이 개발한 영지식 증명 기반 프라이버시 체인으로, Cardano의 파트너 체인입니다. 영지식 기술로 더 넓은 생태계에 합리적인 프라이버시를 제공하는 것이 목표입니다. 단순히 또 하나의 블록체인이 아니라, 비공개 분산 컴퓨팅의 기반을 구축하고 있었습니다. 문서를 읽고 도구를 설정하니 준비는 끝났습니다. 남은 건 시작뿐. 나중에야 깨달았지만, 이것은 해커톤의 시작이 아니라 훨씬 더 큰 무언가의 시작이었습니다.

Building on Midnight

Midnight 위에서 개발하는 것은 쉽지 않았습니다. 테스트넷 환경에서는 끊임없이 배워야 했고, 잦은 변경에 계속 적응해야 했습니다. 불과 몇 주 만에 대규모 네트워크 업데이트가 이루어져 기존 예제가 최신 SDK 버전과 호환되지 않게 되었습니다. 결국 예제를 클론해서 한 줄씩 디버깅해야 했습니다. 예제 저장소는 파일과 디렉터리가 겹겹이 쌓여 있어 어디서부터 시작해야 할지 파악하는 것 자체가 도전이었습니다.

다행히 Midnight의 스마트 컨트랙트 언어인 Compact은 TypeScript와 비슷한 문법을 사용해서 학습 곡선이 완만했습니다. 의미 있는 결과물을 만들겠다는 각오로 밀고 나갔지만, 결국 시도는 부족했습니다. 중간고사가 다가오고 해커톤 제출 마감일이 임박한 상황에서 어려운 결정을 내려야 했습니다. African Blockchain Championship 해커톤에 프로젝트를 제출하지 않기로 한 것입니다. 그래도 아쉬움이 남았습니다. 조금만 노력했더라면 어떻게 되었을까 하는 생각이 계속 맴돌았습니다.

The Extended Deadline

약 일주일 뒤, ABC 팀에서 해커톤 마감 기한이 연장되었다는 이메일이 왔습니다. 기뻤고, 이번에는 전력을 다하겠다고 다짐했습니다. 얼마 지나지 않아 다시 작업에 돌입했습니다. 프로젝트를 마지막으로 손댄 지 몇 주가 지난 사이 네트워크와 SDK가 모두 업데이트되어 있었습니다. 장비를 챙기고 환경을 업데이트한 뒤, 이번에야말로 확실한 결과물을 만들겠다는 새로운 각오로 나섰습니다. 컨트랙트를 작성하고 디버깅을 시작했는데, 수정할 때마다 새로운 복잡성이 생겨났습니다. 한 번의 업데이트가 여러 파일에 걸친 변경을 요구했습니다. 좌절스럽고 지치기도 했지만, 이상하게도 짜릿했습니다. 이 혼돈을 단순화하고 과정을 좀 더 직관적으로 만들 방법을 고민하기 시작했습니다. 바로 그때 워크플로우를 간소화하기 위한 작은 스크립트를 만들기 시작했습니다.

그달 초에 질문을 하려고 Midnight Discord에 가입한 적이 있었습니다. 어느 주, Discord 커뮤니티 콜에 참여했는데, 놀랍게도 비동기로 진행 중인 Midnight 도구 해커톤이 있다는 것을 알게 되었습니다. 그 순간 깨달았습니다. 이것이야말로 처음부터 참가해야 할 해커톤이었다는 것을.

From Personal Tool to Shared Project

개인 도구로 시작한 것이 갑자기 더 큰 의미를 갖게 되었습니다. 해커톤 덕분에 다른 사람들과 작업을 공유하고, Midnight 위에서의 스마트 컨트랙트 개발의 미래를 함께 만들어갈 기회가 생겼습니다. 원래 해커톤 제출물에서 잠시 우회하여 도구를 다듬기 시작했고, 단순히 작동하는 것을 넘어 생태계 입문자에게 진정으로 유용한 것을 만들겠다는 목표를 세웠습니다.

Scaffold Midnight & Create Midnight App

그렇게 Scaffold Midnight이 탄생했습니다. 클론해서 바로 쓸 수 있는 GitHub 스타터 템플릿입니다. 하지만 여기서 멈추지 않았습니다. npm 라이브러리로 만들면 더 유용하겠다는 생각이 들었습니다. 모든 의존성이 번들로 포함되어 설치만 하면 바로 사용할 수 있도록 말입니다. 그래서 프로젝트를 다듬어 create-midnight-app의 첫 버전을 출시했습니다. 하나의 문제를 해결하는 것에 그치고 싶지 않았습니다. 하나를 고칠 때마다 또 다른 문제가 드러났습니다. 여러 차례의 반복을 거쳐 계속 개선했고, 지금은 버전 2.1.7에 이르렀으며 최신 Compact 릴리스와 호환되도록 작업 중입니다. 완벽과는 거리가 멀지만, 그래서 더 흥미롭습니다. 매일 아침 새로운 아이디어, 추가할 기능, 개발자 경험을 개선할 더 나은 방법을 떠올리며 일어납니다!

Create Midnight App

핵심적으로 create-midnight-app은 Midnight을 위한 스캐폴딩 npm 라이브러리로, 전체 설정 과정을 자동화합니다. 개발자는 Compact 컨트랙트 작성에만 집중하면 되고, 나머지는 도구가 처리합니다. 지갑 생성, 파우셋 요청, 잔액 확인, API 설정, CLI 업데이트, 함수와 파일 변경에 따른 임포트 갱신까지 자동으로 이루어집니다. 초기 애플리케이션 설정 시간을 30분 이상에서 단 몇 분으로 줄여줍니다.

이 프로젝트를 진행하면서 짜릿한 순간이 여러 번 있었는데, 특히 기억에 남는 것이 하나 있습니다.

꼭 만들고 싶었던 기능 중 하나는 개발자가 코드 에디터를 벗어나지 않고도 테스트넷 토큰을 요청할 수 있는 방법이었습니다. 가장 단순한 아이디어는 터미널에서 공식 Midnight 파우셋으로 직접 요청을 보내는 것이었습니다. 그런데 문제가 있었습니다. 해당 사이트는 스팸과 악용을 방지하기 위해 Cloudflare Turnstile로 보호되어 자동화된 요청을 차단하고 있었습니다. 해커로서 우회 방법을 찾기 시작했습니다. 검색과 지인들의 조언을 통해 CAPTCHA 보호를 우회한다고 주장하는 여러 유료 도구를 시도했습니다. 한나절을 투자했지만 제대로 작동하는 것이 없었습니다. 그때 제네시스 지갑이 떠올랐습니다. 프라이빗 키가 공개되어 있었기 때문입니다. 그래서 파우셋을 호출하는 대신, 제네시스 지갑에서 사용자 지갑으로 직접 토큰을 전송하는 방식으로 우회했습니다. 장기적인 해결책은 아니고 확장성도 부족하지만, 지금은 작동하며 개발자 경험 간소화에 기여하고 있습니다.

Submission and Recognition

마침내 프로젝트를 제출했고, 심사위원들의 반응은 매우 고무적이었습니다. 새 영감을 얻어 원래 해커톤인 African Blockchain Championship으로 돌아갔습니다. 이 대회를 위해 익명의 검열 저항 투표 플랫폼을 구축했습니다. 아프리카 34개국에서 시민의 약 30~40%가 투표를 공정하고 신뢰할 수 있는 과정으로 여기지 않습니다(Afrobarometer, 2023). 따라서 익명 투표는 선택이 아니라 필수입니다. 제 프로젝트 Privote은 영지식 기술로 유권자의 신원과 투표 무결성을 보호하여 이 문제를 해결하고자 합니다.

제출 마감이 다가오면서 큰 난관에 부딪혔습니다. 프런트엔드에 지갑을 통합하는 부분이었습니다. Developer Relations 엔지니어에게 연락하니 예제 코드를 공유해 주셨지만, 시간 안에 동작시킬 수 없었습니다. 마감이 코앞이라 임기응변을 택했습니다. 백엔드에서 터미널을 통해 지갑과 상호작용하는 커스텀 Chrome 확장 프로그램을 급히 만들어 제출 시간에 맞췄습니다. 현재 버전은 프라이버시 면에서 한계가 있지만, 스마트 컨트랙트가 정상적으로 컴파일되고 Midnight 테스트넷에서 상호작용할 수 있어, 탈중앙화 익명 투표의 가능성을 보여줍니다.

Private Hackathon

The Midnight Community

아직 리소스가 풍부하진 않지만, Midnight 커뮤니티가 이를 충분히 메워줍니다. Discord에서 질문하면 거의 항상 답변을 받을 수 있습니다. 해커톤 기간 중 기술적으로는 경쟁 관계인데도, 서로 진심으로 돕는 분위기입니다. 실제로 해커톤 중에 텔레그램 그룹에서 질문에 답하는 일이 잦았습니다. ABC 해커톤에서는 Create Midnight App 사용법에 대한 워크숍을 진행하여 다른 참가자들이 빠르게 시작하고 생태계를 쉽게 탐색할 수 있도록 도왔습니다.

Midnight 팀의 인정도 정말 감사했습니다. CLI 트랙 1위 수상 외에도, 커뮤니티에서 받은 지지와 감사가 진심으로 따뜻했습니다. 커뮤니티 콜에도 참여할 기회가 있었는데, 이번에는 단순한 참가자가 아니라 발표자로서 여정과 배운 점을 공유할 수 있었습니다.

Why Now is the Time to Build on Midnight

독자 여러분, 지금이야말로 Midnight 생태계에 참여하기 가장 좋은 시기입니다. 거의 항상 해커톤이 진행 중이고(정말 놀랍죠!), 현재도 미니 DApp 해커톤이 열려 있습니다. 배우고, 만들고, 프로젝트를 알릴 수 있는 좋은 기회입니다.

Midnight Dapp Hackathon

저는 현재 해커톤을 위한 새로운 프로젝트를 구축하고 있습니다. 프로젝트를 만드는 것도 즐기지만, 인프라 도구를 개발하는 데서 더 큰 보람을 느끼고 있습니다. 장기 목표는 아무것도 설치하지 않고도 Compact 코드를 작성하고 Midnight 테스트넷과 상호작용할 수 있는 웹 기반 Midnight 플레이그라운드를 만드는 것입니다. 앞으로도 Midnight 팀과 협업하며, 특히 영지식 프로토콜 분야에서 역량을 키워나가고 싶습니다.

Compact 개발자 도구를 소개합니다

· 6 min read
The Compact Team
The Compact Team
Compact Language Team

Compact 개발자 도구의 출시를 발표하게 되어 기쁩니다! 오늘 compact 명령줄 도구를 출시합니다. 이 도구는 Compact 툴체인(예: 컴파일러)을 설치하고, 최신 상태로 유지하며, 툴체인 도구 자체를 실행하는 데 사용됩니다. 개발자 도구는 이제 툴체인을 설치 및 업데이트하고 Compact 컴파일러 등 다른 Compact 도구를 호출하는 "공식" 지원 방법입니다.

혼동을 피하기 위해 "Compact 툴체인"과 "Compact 개발자 도구"를 구분하여 설명하겠습니다. 툴체인에는 컴파일러가 포함되며, 향후 특정 Compact 언어 버전에 맞춘 다른 도구도 추가될 예정입니다. 개발자 도구에는 업데이터가 포함되며, 향후 다양한 언어 버전에서 공통으로 작동하는 도구를 추가로 지원할 예정입니다. 초기 릴리스에서 Compact 개발자 도구는 주로 툴체인 설치 관리에 사용되지만, 앞으로 더 많은 도구가 추가됩니다.

아직 Compact 툴체인의 새 릴리스는 없지만, 이미 새 개발자 도구를 사용하여 현재 버전의 컴파일러를 설치하고 호출할 수 있습니다.

The Old Toolchain Installation Method

이번 릴리스 이전까지 Compact 툴체인 설치는 수동적이고 번거로운 과정이었습니다:

  1. 특정 컴파일러 버전과 플랫폼 아키텍처에 맞는 ZIP 파일을 다운로드해야 했습니다
  2. PATH에 포함된 디렉토리에 압축을 해제해야 했습니다
  3. macOS에서는 두 바이너리(컴파일러, ZK 키 생성기)에 실행 권한을 별도로 부여해야 했습니다

새 툴체인 릴리스가 나올 때마다 이 과정을 반복해야 했습니다. 여러 버전을 동시에 설치하고 관리하는 것도 번거로웠습니다.

The New Toolchain Installation Method

새로운 Compact 툴체인 설치 방법은 compact 명령줄 도구를 한 번 설치하는 것에서 시작합니다. 한번 설치되면, 개발자 도구가 자동으로 업데이트할 수 있습니다. 설치하려면 다음 명령을 실행하세요:

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh

이 명령은 셸 스크립트를 다운로드하여 실행합니다. 사용하는 바이너리 디렉토리를 PATH 환경 변수에 추가하는 방법을 안내해 줍니다.

이 작업을 완료하면 compact 명령줄 도구를 사용할 수 있습니다. 이 도구에는 호출할 수 있는 여러 유용한 서브커맨드가 있습니다. 예를 들어, 툴체인을 최신 버전으로 업데이트하려면 다음 명령을 실행합니다:

compact update

출력은 다음과 같이 나타납니다(예: Apple Silicon macOS 머신에서):

compact: aarch64-darwin -- 0.24.0 -- installed
compact: aarch64-darwin -- 0.24.0 -- default.

이 서브커맨드는 해당 아키텍처에서 사용 가능한 최신 버전의 툴체인으로 전환합니다. 현재 기준으로 0.24.0 버전이며, 도구가 이를 보고합니다. compact 도구는 필요한 경우 툴체인 아티팩트를 다운로드합니다(다운로드 중 진행률 표시줄이 나타납니다). 이미 아티팩트를 다운로드한 경우, 도구는 단순히 기본 버전을 최신 버전으로 전환합니다.

0.25.0 같은 새 Compact 툴체인 릴리스가 나오면, 위와 동일한 서브커맨드를 사용하여 해당 새 버전으로 업데이트합니다.

check 서브커맨드를 사용하여 새 버전이 있는지 확인할 수 있습니다:

compact check

새 버전이 있으면 다음과 같이 표시됩니다:

compact: aarch64-darwin -- Update Available -- 0.24.0
compact: Latest version available: 0.25.0.

현재 0.24.0 버전을 사용 중이며 0.25.0이 사용 가능하다는 것을 보고합니다.

note

새 버전이 실제로 나오기 전까지는 이 출력을 볼 수 없습니다. 대신 최신 버전을 사용 중이라는 메시지를 보게 됩니다:

compact: aarch64-darwin -- Up to date -- 0.24.0

Switching Toolchain Versions

이전 버전을 포함하여 사용 가능한 다른 툴체인으로 "업데이트"할 수도 있습니다. 다음 명령으로 사용 가능한 모든 버전을 나열할 수 있습니다:

compact list

출력은 다음과 같습니다:

compact: available versions

→ 0.24.0 - x86_macos, aarch64_macos, x86_linux
0.23.0 - aarch64_macos, x86_linux
0.22.0 - x86_macos, x86_linux

각 버전과 사용 가능한 플랫폼을 보여줍니다. 화살표는 현재 기본 버전(이 경우 0.24.0)을 나타냅니다. 이 기본값은 compact update를 통해 현재 설정한 버전입니다.

--installed(또는 -i) 플래그를 compact list에 전달하여 실제로 로컬에 다운로드된 버전을 확인할 수 있습니다:

compact list --installed

출력은 대략 다음과 같을 것입니다:

compact: installed versions

→ 0.24.0

update 서브커맨드에 특정 버전을 지정하여 해당 버전으로 전환할 수 있습니다. 예를 들면:

$ compact update 0.23.0
compact: aarch64-darwin -- 0.23.0 -- installed
compact: aarch64-darwin -- 0.23.0 -- default.

$ compact list --installed
compact: installed versions

0.24.0
→ 0.23.0
note

Intel x86 macOS 컴퓨터를 사용하는 경우, 0.23.0 버전은 해당 아키텍처에서 사용할 수 없으므로 업데이트할 수 없습니다. 위와 동일한 명령을 0.22.0 버전으로 시도해 보세요.

compact update 또는 compact update 0.24.0으로 최신 버전으로 다시 전환할 수 있습니다. 이번에는 해당 버전이 이미 로컬에 설치되어 있으므로 아무것도 다운로드되지 않습니다.

Invoking the Compiler

툴체인을 최신 상태로 유지하는 것 외에도, compact 도구는 모든 툴체인 도구를 호출하는 공식 지원 방법이기도 합니다. 현재로서는 유일한 도구가 컴파일러이지만, 향후 더 많은 도구를 구축할 예정입니다. 컴파일러는 compile 서브커맨드로 호출할 수 있습니다:

compact compile <contract file> <output directory>

현재 기본 버전의 툴체인(compact list에서 화살표로 표시되는 것)을 사용합니다.

note

macOS를 사용하는 경우, 더 이상 컴파일러 및 ZK 키 생성 바이너리에 대한 실행 권한을 명시적으로 부여할 필요가 없습니다.

플러스(+) 기호 뒤에 버전 번호를 포함하여 기본값을 재정의하고 특정(이미 설치된) 버전을 사용할 수 있습니다:

compact compile +0.23.0 <contract file> <output directory>

앞으로 compactc 실행 파일을 제거할 계획입니다. 향후 compact compile이 Compact 컴파일러를 호출하는 표준 방법이 될 것입니다.

Built-in Help

compact 도구와 모든 서브커맨드에는 도구 자체에서 제공하는 상세한 도움말 페이지가 있습니다. help 서브커맨드를 사용하거나 --help 플래그를 사용하는 두 가지 방법으로 확인할 수 있습니다.

Compact 도구 자체에 대해서는 compact help 또는 compact --help를 실행하면 정확히 동일한 도움말 페이지를 볼 수 있습니다. 이 페이지는 모든 서브커맨드를 보여줍니다(compile은 현재 페이지 하단에 나열됩니다). 또한 공통 명령줄 옵션도 보여줍니다.

update 같은 특정 서브커맨드에 대해서는 compact help update 또는 compact update --help를 통해 상세 도움말을 볼 수 있습니다. 도움말 페이지를 사용하여 모든 서브커맨드와 옵션을 찾을 수 있습니다.

note

도구의 현재 구현 방식으로 인해 compact help compile로는 컴파일러 도움말을 볼 수 없습니다. 대신 compact compile --help를 사용해야 합니다.

Versions

개발자 도구는 툴체인(즉, 컴파일러) 자체와 별도로 버전이 관리됩니다. 이는 도구가 여러 버전의 툴체인을 관리할 수 있기 때문입니다.

개발자 도구의 버전은 compact --version으로 확인할 수 있습니다. 현재 0.1.0일 것입니다. compact update --version 같은 서브커맨드의 버전도 확인할 수 있습니다. 현재 내장 서브커맨드(즉, 툴체인이 아닌 개발자 도구가 구현한 것)는 모두 개발자 도구와 동일한 버전을 가집니다.

툴체인의 버전은 compact list -i 또는 compact check로 확인할 수 있습니다(후자는 새 버전이 있는지 인터넷 확인을 합니다). 컴파일러를 통해서도 compact compile --version으로 확인할 수 있습니다. 최신 버전을 사용하고 있다면 현재 0.24.0일 것입니다.

이전과 마찬가지로 Compact 언어 자체는 툴체인과 별도로 버전이 관리됩니다. 컴파일러를 통해 compact compile --language-version으로 확인할 수 있습니다.

Keeping the Developer Tools Up to Date

설치가 완료되면, 개발자 도구는 자동으로 최신 상태를 유지할 수 있습니다. compact self check로 업데이트를 확인하고, compact self update로 도구의 최신 버전으로 업데이트할 수 있습니다.

개발자 도구의 이전 버전으로 되돌리는 기능은 제공하지 않습니다. 일반적으로 그럴 필요가 없기 때문입니다. 항상 최신 버전으로 업데이트할 필요는 없지만, 새로운 도구가 추가될 때는 업데이트가 필요합니다. 예를 들어, 향후 릴리스에 compact format 서브커맨드로 사용하는 Compact 포매터가 포함될 예정입니다. 이 포매터를 사용하려면 개발자 도구를 업데이트해야 합니다. 이러한 업데이트는 툴체인과 개발자 도구 양쪽의 릴리스 노트에서 안내합니다.

How it Works

현재 도구는 단순하지만 아키텍처가 유연하여 향후 다양한 개발자 도구를 추가로 지원할 수 있습니다. 도움말 페이지를 보면 --directory 명령줄 플래그를 확인할 수 있습니다. 이 플래그로 툴체인 위치를 지정합니다. 기본값은 홈 디렉토리의 .compact 디렉토리입니다.

해당 디렉토리를 살펴보면 binversions라는 두 개의 하위 디렉토리가 있습니다. versions 하위 디렉토리에는 각 설치된 버전의 별도 하위 디렉토리가 있으며, 해당 버전의 툴체인 아티팩트를 포함합니다. bin 하위 디렉토리는 기본 버전을 나타내며, 설치된 버전 중 하나에 대한 심볼릭 링크입니다.

명령줄 도구가 업데이트를 확인하고, 필요하면 툴체인 아티팩트를 다운로드하고 압축을 해제합니다. 설치된 버전 간 전환은 bin 심볼릭 링크가 가리키는 대상을 변경하는 것으로 이루어집니다. 툴체인 호출은 bin 디렉토리의 기본 실행 파일을 실행하거나, 필요한 경우 특정 버전의 실행 파일을 직접 실행합니다.

Compact 심층 분석 파트 2 - Circuit과 Witness의 동작 원리

· 9 min read
Kevin Millikin
Language Design Manager

이 블로그 글은 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, []>;

CircuitContextCircuitResults 타입은 컴파일러가 사용하는 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 선언에서 inputAlignedValue 타입임을 알 수 있습니다. 이것은 Compact 런타임의 타입 별칭 AlignedValue입니다. 그리고 해당 TypeScript 선언에서 alignmentvalue라는 한 쌍의 속성을 가지고 있음을 알 수 있습니다.

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 값을 반환하므로, toValuealignment를 사용하여 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의 실제 구현을 더 자세히 살펴보고 온체인 런타임을 어떻게 사용하는지 알아보겠습니다.

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가 생성된 코드에서 어떻게 구현되는지 살펴보겠습니다.