Skip to main content

Smart contracts on Midnight

스마트 컨트랙트에 어느 정도 익숙하더라도, 데이터 보호를 위한 스마트 컨트랙트 설계에는 특유의 과제와 관점이 있습니다. 이 문서에서는 Midnight가 일반적인 공개형 스마트 컨트랙트 솔루션과 어떻게 다른지, 그리고 이러한 차이가 Midnight에서의 컨트랙트 구성에 어떤 영향을 미치는지를 간략히 살펴봅니다.

Replicated state machines

모든 블록체인 시스템은 본질적으로 복제된 상태 머신입니다. 원장 상태를 유지하며, 트랜잭션에 의해 이를 변경합니다. 블록체인마다 어떤 트랜잭션을 유효하다고 보는지, 그리고 그 트랜잭션이 원장 상태에 어떤 영향을 미치는지가 다릅니다.

스마트 컨트랙트를 지원하는 블록체인에서는 트랜잭션이 이후 트랜잭션이 충족해야 할 유효성 기준의 일부를 프로그래밍할 수 있습니다. 여기서는 account 모델을 중심으로 설명합니다. 이 모델에서 컨트랙트는 트랜잭션을 통해 배포되어 블록체인상에서 고유 주소를 부여받으며, 자신과 상호작용하는 트랜잭션의 유효성 기준과 상태 전환을 정의할 수 있습니다.

다음 예제로 이 개념을 설명합니다. 컨트랙트 상태에 저장된 숫자의 인수를 플레이어가 추측하는 게임을 지원하는 컨트랙트를 생각해 보겠습니다. 올바르게 추측하면 상대방을 위한 다음 숫자를 설정할 수 있습니다. 추측 시 플레이어는 현재 숫자에 대한 두 개의 인수와 새 숫자를 구성할 두 개의 인수를 제공합니다. 추측 로직은 다음 의사 코드로 표현됩니다:

note

이것은 의사 코드이며, 실제로 동작하는 Compact 프로그램이 아닙니다.

def guess_number(guess_a, guess_b, new_a, new_b):
assert(guess_a != 1 and guess_b != 1 and new_a != 1 and new_b != 1,
"1 is too boring a factor")
assert(guess_a * guess_b == number,
"Guessed factors must be correct")
number = new_a * new_b

컨트랙트에서 인수 대신 새 숫자를 직접 입력받을 수도 있지만, 그러면 실수나 고의로 소수를 전달하여 게임을 망칠 수 있습니다. '흥미로운' 인수를 반드시 제공하도록 강제하면 이런 가능성을 차단할 수 있습니다.

컨트랙트가 배포되면 이 프로그램은 보통 압축된 바이트코드 형태로 컨트랙트의 초기 상태와 함께 직접 온체인에 올라갑니다. 개념적으로 원장 상태는 다음과 같은 모습이 됩니다:

contracts:
"<contract address>":
state:
number: 35
entryPoints:
guess_number: |
def guess_number(...):
// ...

이후 트랜잭션이 함수에 입력값을 전달하여 이 컨트랙트를 호출할 수 있습니다. 예를 들어:

transaction:
type: "call"
address: "<contract address>"
entryPoint: "guess_number"
inputs: [5, 7, 2, 6]

처리 시 노드는 다음을 수행합니다:

  • <contract address>state와, <contract address>guess_number에 해당하는 프로그램을 조회
  • 상태와 inputs를 사용하여 프로그램을 실행
  • 프로그램이 성공하면 새 state를 저장.

Midnight contracts, conceptually

위 프로그램이 이 게임에 적합하지 않다는 점을 눈치챘을 수도 있습니다. 새 숫자가 설정될 때마다 그 숫자의 인수가 트랜잭션에 공개적으로 드러나기 때문입니다. 이기고 싶은 사람은 인수를 읽어서 자신의 '추측'으로 쓰면 됩니다. 이런 방식이라면 게임이 될 수 없습니다.

이 문제를 우회하기 위해, 블록체인과 트랜잭션 처리 방식은 잠시 잊어 봅시다. 대신 컨트랙트를 온체인 상태와 상호작용하면서 사용자의 로컬 머신에서 임의의 코드를 호출할 수 있는 대화형 프로그램으로 생각해 보세요.

이런 설정에서는 위의 의사 코드 프로그램을 다음과 같이 다시 작성할 수 있습니다:

def guess_number():
(a, b) = local.guess_factors(number)
assert(a != 1 and b != 1, "1 is too boring a factor")
assert(a * b == number, "Guessed factors must be correct")
(a, b) = local.new_challenge()
assert(a != 1 and b != 1, "1 is too boring a factor")
number = a * b

이 프로그램은 이전 섹션보다 길지만, 조금 더 많은 역할을 합니다. 숫자가 어디서 오는지 명시적으로 보여주는데, 각각 guess_factors 또는 new_challenge에 대한 local 호출을 통해 얻습니다. 실제로 이런 흐름은 원래부터 발생하는 일이며, 트랜잭션이 성공하도록 입력값을 사전에 계산해야 합니다. 여기서 API가 명확해지는데, guess_factors 루틴은 추측할 숫자도 함께 전달받습니다(이전에는 별도로 파악해야 했습니다).

체인 관점에서 이 프로그램의 상호작용은 다음과 같습니다:

  • number 원장 필드 읽기
  • number 원장 필드 쓰기.

이 중 어느 것도 인수의 세부 사항을 노출하지 않습니다. 추측한 인수도, 새 도전 과제를 구성하는 인수도 마찬가지입니다.

이 접근 방식의 실질적인 과제는 컨트랙트가 올바르게 사용되었음을 어떻게 보장하느냐입니다. local 호출의 경우 이는 감수하는 위험입니다. 예를 들어 guess_factors가 어떻게 동작하는지까지 규정할 필요는 없고, 올바른 추측을 출력하는지만 확인하면 됩니다(따라서 입력 검증이 필요합니다). 컨트랙트 프로그램 자체에 대해서는, 다른 사용자가 올바른 프로그램을 실행했으며 컨트랙트 상태에 대한 변경이 정당하다는 확신을 갖고 싶습니다.

Transcripts and ZK Snarks

이 모든 것을 가능하게 하는 핵심 기술이 ZK Snark입니다. ZK Snark(그리고 더 넓게는 영지식 증명)는 여러 변수에 값을 할당하여 명확한 수학적 조건 집합을 만족시키는 방법을 알고 있음을 증명하는 수단입니다. 이러한 변수 중 일부는 공개이고, 대부분은 그렇지 않습니다.

위 프로그램은 각각 별도의 환경에서 실행되는 세 부분으로 깔끔하게 분리됩니다: local 부분, ledger 부분, 그리고 이 둘을 연결하면서 핵심 프로그램 로직을 인코딩하는 접착 역할의 부분입니다. 이 '접착제'는 ZK Snark로 변환 가능한 일련의 변수 할당과 방정식으로 바뀔 수 있으며, 원장 상호작용은 온체인에서 실행되는 프로그램으로 변환됩니다.

현재 상태 355 * 7로 인수분해하고 2 * 6으로 교체하는 예제는 다음과 같습니다:

오프체인 코드


(a, b) = local.
guess_factors(n1)




(a, b) = local.
new_challenge()




프라이빗 transcript
a1 = 5
b1 = 7
a2 = 2
b2 = 6
회로 내 코드
def guess_number():



assert(a != 1 and b != 1, "...")


assert(a * b == n2, "...")


assert(a != 1 and b != 1, "...")

n3 = a * b

회로 제약 조건
guess_number:
inputs:
public: n1, n2, n3,
transcript code
private: a1, b1, a2, b2
constraints:
a1 != 1
b1 != 1
a1 * b1 = n2
a2 != 1
b2 != 1
n3 = a2 * b2
// 공개 transcript 형태를
// 강제하기 위한 추가 제약 조건
온체인 코드

n1 = number




n2 = number






number = n3
공개 transcript
n1 = 35
n2 = 35
n3 = 12

assert(n1 == number)
assert(n2 == number)
number = n3

함수 호출, 조건문, 반복문, 해시 함수 호출과 같은 복잡한 프리미티브를 포함하는 더 복잡한 프로그램도 이 방식으로 변환할 수 있습니다. 이러한 프로그램을 작성하는 데 사용하는 언어에 대해서는 컨트랙트 작성하기 섹션을 참조하세요.

위 예제에서 공개 값 n1, n2, n3에 대해, 이 방정식들을 만족시키는 a1, b1, a2, b2의 값을 알고 있음을 증명할 수 있습니다. 이 증명은 누군가가 실제로 위 프로그램을 실행했다는 것을 의미하는 것이 아니라, 프로그램의 규칙이 준수되었음을 _증명_합니다. 이것이 바로 회의적인 사용자가 진정으로 확인하고 싶은 부분입니다.

할당 시퀀스인 n1, n2, n3과 이를 생성하거나 사용하는 프로그램을 공개 transcript라고 하며, a1, b1, a2, b2비공개 transcript입니다. 공개 transcript는 바이트코드[^1]로 인코딩되며, 이 바이트코드의 형태는 circuit에 의해 직접 강제됩니다.

Midnight의 트랜잭션은 본질적으로 공개 transcript와 이 transcript의 정당성에 대한 영지식 증명으로 구성됩니다. 각 트랜잭션은 특정 컨트랙트와 해당 컨트랙트의 특정 circuit[^2]에 대해 생성됩니다. 온체인에는 guess_number()의 코드를 저장하는 대신, guess_number()에 대한 영지식 증명을 검증하는 데 사용되는 암호학적 키가 저장됩니다. 이 키는 위 circuit에 나열된 모든 방정식을 암호학적으로 인코딩하고 강제합니다.

대략적으로 상태는 다음과 같은 모습입니다:

contracts:
"<contract address>":
state:
number: 35
entryPoints:
guess_number: "<verifier key>"

이 상태에 대해 생성된 트랜잭션은 다음과 같은 모습입니다:

note

이것은 트랜잭션의 개략적인 형태입니다.

transaction:
type: "call"
address: "<contract address>"
entryPoint: "guess_number"
transcript: |
n1 = 35
n2 = 35
n3 = 12
assert(n1 == number)
assert(n2 == number)
number = n3
proof: "<zero-knowledge proof>"

이 트랜잭션을 검증할 때, 먼저 verifier key에 대해 proof가 유효한지 확인한 다음 transcript를 실행합니다. 여기서 현재 상태가 예상과 일치하는지 확인합니다. 현재 number35아니라면 트랜잭션은 더 이상 유효하지 않습니다 -- 트랜잭션을 생성한 사람이 결국 35의 인수를 추측한 것이 아니기 때문입니다. 트랜잭션이 성공하면 상태가 12를 포함하도록 갱신되며, 중요한 점은 트랜잭션이 추측이든 새 숫자든 어떤 인수가 사용되었는지 아무에게도 알려주지 않는다는 것입니다!

number 검사가 왜 두 번 나오는지 의문이 들 수 있는데, 실제로 같은 값을 여러 번 읽을 필요는 없습니다. 하지만 외부 상호작용을 이런 식으로 처리하면 여기서 수행하는 연산이 임의적일 수 있게 됩니다. 영지식 증명은 read가 무엇인지, n1n2가 반드시 같은 값인지를 알지 못하며, 이 덕분에 incrementinsert 같은 더 유용한 연산을 사용할 수 있습니다. 이는 위의 35 사례처럼 상태 불일치로 트랜잭션이 무효화되는 것을 방지하는 데 특히 유용합니다. increment를 동시에 두 번 호출하는 것과, 값을 read하고 1을 더한 뒤 다시 write하는 시퀀스를 동시에 두 번 실행하는 것을 비교해 보면, increment는 거의 항상 성공하는 반면 read-add-write 시퀀스는 실패하기 쉽습니다.

Putting value at stake

가치의 개념이 이 모델에 어떻게 들어맞는지는 바로 와닿지 않습니다. 공개 블록체인에서는 스마트 컨트랙트가 상태뿐 아니라 가치를 보유하기 쉬워서, 컨트랙트에 자금을 입금하고 출금할 수 있습니다. 이런 가치 이전은 많은 애플리케이션에 핵심적이므로, 데이터 프라이버시를 보존하는 환경에서도 이를 구현해야 합니다.

Midnight의 토큰은 현재 Zswap 구현을 사용하며, UTXO와 유사하게 작동하면서 토큰의 값, 유형, 보유자를 차폐합니다. 완전한 차폐의 예외는 컨트랙트가 보유한 자금입니다. 이들의 가치와 유형은 기본적으로 여전히 차폐되지만, 보유와 해제는 컨트랙트에 연결됩니다.

이러한 UTXO는 컨트랙트에서 개별 코인으로 표현되며, 명시적으로 수신될 때까지는 단순한 데이터에 불과합니다. 수신되면 다른 데이터와 동일하게 처리할 수 있으며, 공개 저장, 암호화, 비공개 저장 여부는 컨트랙트 설계에 달려 있습니다. 컨트랙트는 필요에 따라 이 코인을 다른 컨트랙트나 사용자 주소로 전송할 수 있습니다.

코인의 수신전송은 특별한 의미를 가집니다. 공개 transcript에 operation으로 기록되지만 컨트랙트 상태에는 영향을 미치지 않습니다. 대신 동일한 트랜잭션에 해당하는 input 또는 output이 포함되어야 하며, 이를 통해 컨트랙트가 존재하지 않는 자금을 수신하거나 보유하지 않은 자금을 전송하는 것을 방지합니다.

의사 코드로 이 예제에 배팅을 추가하면 다음과 같습니다:

def guess_number(new_wager):
(a, b) = local.guess_factors(number)
builtin.send(wager, local.self())
assert(a != 1 and b != 1, "1 is too boring a factor")
assert(a * b == number, "Guessed factors must be correct")
(a, b) = local.new_challenge()
assert(a != 1 and b != 1, "1 is too boring a factor")
number = a * b
builtin.receive(new_wager)
wager = new_wager

Factoring and keys

인수분해 예제가 단순한 장난감 문제로 보이고 다소 임의적일 수 있지만, 큰 정수의 인수분해는 암호학에서 매우 중요한 문제입니다. 큰 수의 인수를 아는 것은 RSA 암호화 알고리즘의 근간이며, 위 추측 게임은 RSA 공개 키에 대한 비밀 키를 알고 있음을 증명하는 것과 같습니다. 이것은 영지식 증명의 강력함을 보여주는데, 서명 체계와 동일한 역할을 수행할 수 있기 때문입니다. 비밀 키를 알고 있음을 증명할 수 있을 뿐 아니라, 동일한 사람이 특정 행위를 했음도 증명할 수 있어 사실상 그 행위에 서명하는 것과 같습니다.

실제로 인증이 목적이라면 이 구성이 가장 효율적이지는 않습니다. 해시 함수의 원상에 대한 지식 증명(즉, pk = H(sk)에서 sk를 아는 것)이 대부분의 경우 더 단순한 대안입니다.


[^1] operation의 인코딩 방식에 대한 고급 자료는 Midnight의 온체인 VM, Impact의 세부 사항을 참조하세요.

[^2] Circuit이라는 이름은 영지식 증명의 컴파일이 특수 목적의 논리 회로를 조립하는 과정과 많이 유사하기 때문에 붙여졌습니다.