Skip to main content
For the complete documentation index, see llms.txt

Writing a contract

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

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

pragma language_version 0.26;

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 타입은 다양한 연산을 지원합니다.

The 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 computations​

세 번째 컨텍스트는 사용자의 로컬 머신입니다. 이는 사용자의 머신에서 실행되는 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를 다르게 구현할 수 있으므로, 그 결과를 컨트랙트 내에서 무조건 신뢰해서는 안 됩니다.

The full contract​

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

pragma language_version 0.26;

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​

이 예제에서 어떤 데이터가 비공개이고, 그 비공개 데이터 중 무엇이 기밀로 남으며, 반대로 무엇이 공개되는지는 바로 파악하기 어려울 수 있습니다. 다행히 이는 명확하게 정의되어 있습니다. 비공개 데이터란 circuit 인수와 witness 반환 값에 담겨 있거나 그로부터 파생된 데이터를 말합니다. 비공개 데이터는 다음의 경우에만 공개된 것으로 간주됩니다. ledger 연산에 전달될 때, exported circuit에서 반환될 때, cross-contract 호출로 다른 컨트랙트에 전달될 때, assert 하위 표현식의 boolean 값을 계산하는 데 쓰일 때, 그리고 조건부로 실행되는 ledger 연산·cross-contract 호출·exported circuit 반환으로 이어지는 제어 흐름을 결정하는 데 쓰일 때입니다. 한 가지 예외로, exported circuit의 인수에 담긴 데이터가 그 circuit에서 반환되는 경우는 공개로 보지 않습니다. 호출자가 이미 알고 있는 값이기 때문입니다. 대부분의 경우 비공개 데이터의 공개는 disclose() 래퍼로 명시적으로 선언해야 합니다. 유일한 예외는 assert 하위 표현식의 값을 계산하는 데 비공개 데이터를 쓰는 경우로, 비공개 데이터의 속성을 단언하는 데 워낙 자주 쓰이므로 암묵적 공개가 허용됩니다. 컨트랙트는 disclose() 래퍼를 어디에 둘지 상당한 재량을 갖습니다. 항상은 아니지만 대개 공개 지점에 최대한 가까운 곳이 가장 좋습니다. 공개하면 안 되는 경로에서까지 공개 대상으로 표시될 수 있는 지점에서 데이터를 너무 일찍 공개하는 것은 대체로 좋지 않습니다.

위 예제에서 v 매개변수의 값은 비공개 데이터를 담은 것으로 간주되며, 이 값을 ledger value 필드에 저장하는 지점에서 그 공개를 disclose(v)로 명시적으로 선언해야 하고 또 그렇게 하고 있습니다. secretKey witness의 값도 비공개 데이터를 담은 것으로 간주되며, 이를 담은 struct의 persistent hash를 ledger authority 필드에 저장하는 지점에서 그 공개를 명시적으로 선언합니다. secretKey()의 값은 clear의 assert를 통해 암묵적으로도 공개됩니다.

secretKey()가 반환하는 비밀 키가 완전히 기밀로 유지되지는 않지만, 비밀 키를 담은 값의 해시만 공개되므로 부분적인 기밀성은 지켜집니다.

round 매개변수를 공개되는 값에 비밀 키와 함께 포함하는 이유도 여기에 있습니다. 비밀 키의 해시만 단독으로 노출하면 동일한 사용자가 여러 라운드에 걸쳐 데이터를 게시할 때 서로 연결될 수 있습니다. 공개 키 계산에 라운드 매개변수를 포함하면 이러한 연결 가능성이 사라집니다.

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

임의의 이진 문자열을 해시하여 키로 사용하는 이 패턴은 매우 강력합니다. 비슷한 개념으로 commitment 스킴이 있는데, 임의의 데이터를 랜덤 nonce와 함께 해시하는 방식입니다. 그 결과를 원본 데이터를 드러내지 않고 안전하게 ledger 상태에 저장할 수 있습니다. (nonce는 절대 재사용하면 안 됩니다. 재사용하면 동일한 nonce와 값을 가진 commitment끼리 연결될 수 있습니다.) 이후에 값과 nonce를 공개하여 commitment를 "열거나", 올바른 값과 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는 임의 데이터에 대한 commitment 함수입니다. transient* 함수는 값이 상태에 보관되지 않을 때만 사용해야 하며, persistent* 출력은 컨트랙트의 ledger 상태에 저장하기에 적합합니다.

Proof of in-circuit computations​

in circuit에서 수행되는 계산은 '정확'하다고 증명됩니다. 애플리케이션이 변조된 계산을 사용하려 하면, 컨트랙트 배포 시점에 저장된 verifier key에 대해 검증을 통과하지 못하는 proof가 나온다는 의미입니다. Compact circuit 안에 텍스트로 나타나는 모든 코드는 in circuit입니다. circuit 밖에서 실행되는 코드는, circuit에서 호출된 witness 함수 내부를 포함해 in circuit으로 보지 않습니다.

Next steps​

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

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