Skip to main content

Writing a contract

이 페이지는 공개적으로 접근 가능한 값을 관리하고 get, set, clear 연산을 지원하는 간단한 스마트 컨트랙트의 구성을 단계별로 안내합니다. 값이 공개이므로 누구나 get을 호출할 수 있지만, 값이 현재 설정되어 있으면 마지막으로 set을 호출한 사용자만 clear할 수 있으며, 다시 set하기 전에 반드시 clear해야 합니다.

시작하려면, 컨트랙트는 사용 중인 언어 버전을 지정하고, Midnight의 표준 라이브러리를 가져오고, 현재 상태를 나타내는 enum을 선언합니다:

pragma language_version 0.22;

import CompactStandardLibrary;

enum State {
UNSET,
SET
}

enum 선언 외에도, 사용자 정의 데이터는 struct로 정의할 수 있습니다. 자세한 내용은 언어 레퍼런스를 참고하세요.

The ledger section

Compact 스마트 컨트랙트의 핵심은 ledger 섹션으로, 온체인에 저장되는 상태를 정의합니다. 이 예제에서는 값을 clear할 수 있는 권한이 있는 사용자를 식별하는 키, 값 자체(64비트 부호 없는 정수), 컨트랙트의 현재 상태를 저장합니다.

아래에서 설명하듯이 익명성을 유지하기 위한 round 카운터도 추가해야 합니다.

스마트 컨트랙트의 온체인 상태 필드는 각각 ledger 선언으로 정의합니다. constructor를 사용하면 컨트랙트 배포 시 ledger 필드를 초기화할 수 있습니다. 이 경우의 ledger 선언은 다음과 같습니다:

export ledger authority: Bytes<32>;

export ledger value: Uint<64>;

export ledger state: State;

export ledger round: Counter;

constructor(sk: Bytes<32>, v: Uint<64>) {
authority = disclose(publicKey(round, sk));
value = disclose(v);
state = State.SET;
}

circuit publicKey(round: Field, sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<3, Bytes<32>>>(
[pad(32, "midnight:examples:lock:pk"), round as Bytes<32>, sk]);
}

ledger 섹션 외에도, constructor에서 필드 이름으로 ledger 상태의 항목을 참조하는 기본적인 상태 조작 방법을 볼 수 있습니다. clear에서 보여주듯이 많은 ledger 타입은 다양한 연산을 지원합니다.

circuit definitions

위 예제에서 이미 사용자의 publicKey를 계산하는 circuit를 볼 수 있습니다. Compact에서 circuit는 다른 프로그래밍 언어의 함수와 비슷하지만, 컴파일 시점에 계산 범위가 고정됩니다. 스마트 컨트랙트의 circuit는 사용자가 트랜잭션에서 직접 호출할 수 있는 주요 진입점이 될 수도 있습니다. 앞서 언급한 세 가지 진입점 중 get은 아무 제한 없이 간단히 구현됩니다:

export circuit get(): Uint<64> {
assert(state == State.SET, "Attempted to get uninitialized value");
return value;
}

여기서 export는 이 circuit를 스마트 컨트랙트의 진입점으로 표시하고, assert는 컨트랙트가 올바른 상태에 있을 때만 사용할 수 있도록 보장합니다. 언어 레퍼런스에서 circuit의 허용 가능한 내용을 자세히 설명합니다.

Local state and computation

세 번째 컨텍스트는 사용자의 로컬 머신입니다. 이는 사용자의 머신에서 실행되는 DApp으로 직접 프로그래밍할 수 있습니다. Compact는 witness[^1]를 통해 로컬 컨텍스트로 콜아웃할 수 있으며, circuit와 비슷한 방식으로 선언합니다. 이 예제에서는 사용자의 비밀 키를 가져오기 위해 witness가 필요합니다. 비밀 키는 사용자의 머신에서만 유지되어야 하기 때문입니다.

이에 대한 코드는 다음과 같습니다:

witness secretKey(): Bytes<32>;

export circuit set(v: Uint<64>): [] {
assert(state == State.UNSET, "Attempted to set initialized value");
const sk = secretKey();
const pk = publicKey(round, sk);
authority = disclose(pk);
value = disclose(v);
state = State.SET;
}

export circuit clear(): [] {
assert(state == State.SET, "Attempted to clear uninitialized value");
const sk = secretKey();
const pk = publicKey(round, sk);
assert(authority == pk, "Attempted to clear without authorization");
state = State.UNSET;
round.increment(1);
}

witness는 Compact 소스 코드에서 구현하지 않습니다. 구현은 DApp의 TypeScript 코드에서 담당합니다. 각 사용자가 witness를 다르게 구현할 수 있으므로, 그 결과를 컨트랙트 내에서 무조건 신뢰해서는 안 됩니다.

Full contract

모두 합치면, 전체 예제는 다음과 같습니다:

pragma language_version 0.22;

import CompactStandardLibrary;

enum State {
UNSET,
SET
}

export ledger authority: Bytes<32>;

export ledger value: Uint<64>;

export ledger state: State;

export ledger round: Counter;

constructor(sk: Bytes<32>, v: Uint<64>) {
authority = disclose(publicKey(round, sk));
value = disclose(v);
state = State.SET;
}

circuit publicKey(round: Field, sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<3, Bytes<32>>>(
[pad(32, "midnight:examples:lock:pk"), round as Bytes<32>, sk]);
}

export circuit get(): Uint<64> {
assert(state == State.SET, "Attempted to get uninitialized value");
return value;
}

witness secretKey(): Bytes<32>;

export circuit set(v: Uint<64>): [] {
assert(state == State.UNSET, "Attempted to set initialized value");
const sk = secretKey();
const pk = publicKey(round, sk);
authority = disclose(pk);
value = disclose(v);
state = State.SET;
}

export circuit clear(): [] {
assert(state == State.SET, "Attempted to clear uninitialized value");
const sk = secretKey();
const pk = publicKey(round, sk);
assert(authority == pk, "Attempted to clear without authorization");
state = State.UNSET;
round.increment(1);
}

confidentiality

이 예제에서 무엇이 기밀로 유지되고 무엇이 강제되는지 바로 파악하기 어려울 수 있습니다. 다행히 둘 다 명확하게 정의되어 있습니다:

  • ledger 필드가 아니고 ledger 연산의 인수나 반환 값이 아닌 모든 데이터는 기밀로 유지됩니다
  • witness 함수 외부에서 수행되는 모든 계산은 정확성이 강제됩니다.

구체적으로, secretKey의 출력은 기밀로 유지되면서 clear할 때 해시가 올바른 값인지가 강제됩니다.

round 매개변수가 필요한 이유도 여기에 있습니다. pk "공개 키"는 기밀이 아니므로, 동일한 사용자가 여러 라운드에 걸쳐 데이터를 게시하면 서로 연결될 수 있습니다. 공개 키 계산에 라운드 매개변수를 포함하면 이러한 연결 가능성이 사라집니다.

"비밀 키"와 "공개 키"라는 용어를 사용하지만, 이 키들은 공개 키 암호화와는 관계없습니다. 단순히 이진 문자열과 그 해시일 뿐입니다. 영지식 circuit는 해시의 역상 저항성만으로도 디지털 서명과 유사한 효과를 구현할 수 있기 때문입니다.

임의의 이진 문자열을 해시하여 키로 사용하는 이 패턴은 매우 강력합니다. 비슷한 개념으로 커밋먼트 스킴이 있는데, 임의의 데이터를 랜덤 nonce와 함께 해시하는 방식입니다. 그 결과를 원본 데이터를 드러내지 않고 안전하게 ledger 상태에 저장할 수 있습니다. (nonce는 절대 재사용하면 안 됩니다. 재사용하면 동일한 nonce와 값을 가진 커밋먼트끼리 연결될 수 있습니다.) 이후에 값과 nonce를 공개하여 커밋먼트를 "열거나", 올바른 값과 nonce를 보유하고 있음assert로 증명할 수 있으며, 이때 실제 값은 공개되지 않습니다.

CompactStandardLibrary 모듈은 이러한 용도를 위해 다음 circuit를 제공합니다:

circuit transientHash<T>(value: T): Field;
circuit transientCommit<T>(value: T, rand: Field): Field;
circuit persistentHash<T>(value: T): Bytes<32>;
circuit persistentCommit<T>(value: T, rand: Bytes<32>): Bytes<32>;

*Hash 변형은 기본 해시 함수이고, *Commit는 임의 데이터에 대한 커밋먼트 함수입니다. transient* 함수는 값이 상태에 보관되지 않을 때만 사용해야 하며, persistent* 출력은 컨트랙트의 ledger 상태에 저장하기에 적합합니다.

Next steps

Compact 레퍼런스에서 Compact 언어의 완전한 상세 내용을 확인할 수 있습니다. Midnight DApp으로 할 수 있는 더 흥미로운 내용이 궁금하다면 더 자세한 예제를 참고하세요. 이 섹션은 Compact 언어에 초점을 맞추었지만, Midnight 작동 방식 섹션에서 ledger와 그 기능에 대해 더 자세히 다룹니다.

[^1] witness라는 이름은 영지식 문헌에서 유래합니다. 대략적으로 어떤 진술을 믿기 위해 필요한 증거를 의미합니다. 이 예제에서는 clear 호출이 허용됨을 믿기 위해 필요한 증거입니다.