For the complete documentation index, see llms.txt
Private party contract
이 private party 튜토리얼은 다음 기능을 다루는 초급 수준의 예제입니다:
- Unshielded token 송수신 (NIGHT)
- privacy boundary
- persistentCommit
- DApp 전용 public key
이 튜토리얼에는 두 가지 핵심 구성 요소가 있습니다:
- Compact contract
- 테스트 스크립트
witness 없이 비공개 데이터를 다루는 안전한 Compact contract를 작성하고, MidnightJS 기반 로컬 devnet 테스트 스크립트로 컨트랙트의 동작을 검증하는 과정을 다룹니다. 코드를 복사해 붙여 넣기보다는 각 코드 블록을 직접 타이핑하며 따라가세요.
이 튜토리얼의 초점은 Compact에 있으며, 테스트 스크립트는 미리 제공됩니다.
Prerequisites
시작하기 전에 다음 사항을 확인하세요:
- 툴체인 설치 완료
- Node.js v22 이상
Problem analysis
private party contract는 organizer가 참석자의 privacy를 특정 경계까지 유지하면서 RSVP 알림을 모을 수 있게 해 줍니다. 그 경계를 넘으면 참석자는 공개됩니다. 이 튜토리얼에서는 이 경계를 "privacy boundary"라고 부르며, Midnight DApp을 개발할 때 반드시 이해해야 하는 요소입니다. 이 예제에서는 Unshielded NIGHT token으로 입장료를 내는 순간 privacy boundary를 넘게 됩니다.
Compact에서 privacy는 기본값이며, 데이터를 비공개에서 공개로 전환하는 일은 DApp 개발자인 여러분이 직접 책임지고 처리해야 합니다. 다만 Compact의 일부 기능은 항상 공개입니다.
예를 들어 ledger 쓰기, export된 circuit의 반환값, 컨트랙트 간 호출, Unshielded token 전송은 모두 공개됩니다. 앞의 세 가지 영역을 지나는 데이터는 Compact의 해싱과 commitment 기법으로 가릴 수 있습니다. 그렇다면 Unshielded token은 어떨까요?
Unshielded token은 항상 공개입니다.
이 튜토리얼에서는 Unshielded token 전송 전까지 참석자의 privacy를 완전하게 유지하다가, 전송 시점에 privacy boundary를 넘으면서 더 이상 privacy가 유지되지 않는 과정을 보여 줍니다.
Program design
private party 프로그램은 참석자가 신원을 감추는 DApp 전용 public key로 파티에 RSVP하게 하여 참석자의 privacy를 유지합니다. organizer는 입장료를 NIGHT token으로 받습니다. NIGHT은 Unshielded token이므로 이 거래는 공개됩니다. 참석자는 입장료를 내고 파티에 도착하기 전까지 비공개로 남습니다.
organizer 역시 컨트랙트가 청구된 입장료를 지급하기 전까지는 DApp 안에서 privacy를 유지합니다.
Operational steps
먼저 컨트랙트의 동작 측면을 생각해 봅니다. 컨트랙트는 무엇을 해야 할까요?
순서대로 정리하면 다음과 같습니다:
- 컨트랙트를 배포하고 organizer가 초기값을 설정
- 참석자가 비공개로 파티에 RSVP
- organizer만 (그리고 오직 organizer만) 파티를 시작할 수 있도록 권한 부여
- 이미 RSVP한 참석자가 파티에 check-in
- 입장 마감 (organizer 전용)
- organizer에게 입장료 지급
Data: private by default
Compact는 데이터를 기본적으로 비공개로 취급합니다. 이 애플리케이션에서는 private state 데이터를 설정하고 Compact circuit에 비공개로 전달하는 과정을 보여 줍니다. Unshielded token 전송이 일어나기 전까지는 어떤 참석자 데이터도 공개되지 않습니다.
Compact는 모든 데이터를 기본적으로 비공개로 취급하지만, 일부 영역은 항상 공개입니다. 다음이 여기에 해당합니다:
- ledger 필드
- export된 circuit의 반환값
- 컨트랙트 간 호출
- Unshielded 트랜잭션
Access control
Midnight 블록체인 같은 개방형 공개 시스템에서 스마트 컨트랙트를 개발할 때 중요한 요소 중 하나가 circuit 접근 제어입니다. 컨트랙트를 블록체인에 배포하는 순간, 그 circuit은 사실상 누구나 호출할 수 있는 공개 API가 됩니다. 안전한 Compact contract라면 특정 사용자나 특정 유형의 사용자만 쓰도록 만든 circuit의 접근을 보호해야 합니다.
Compact tutorial
Compact는 DApp 개발자가 공개 데이터와 비공개 데이터의 조합을 정밀하게 정의할 수 있도록 상당한 유연성을 제공합니다. 이 튜토리얼은 주로 Compact 코드에 집중하며, 나머지 셋업 코드는 저장소에 포함되어 있습니다.
Setup
먼저 private party 저장소를 클론합니다:
git clone git@github.com:midnightntwrk/example-private-party.git
텍스트 에디터에서 프로젝트를 열고 contracts 디렉터리로 이동한 뒤 새 .compact 파일을 만듭니다.
cd example-private-party/contracts && touch private-party.compact
가장 먼저 언어 버전과 import를 선언합니다:
pragma language_version 0.23;
import CompactStandardLibrary;
그런 다음 커스텀 상태 enum을 선언합니다:
export enum PartyState {
NOT_STARTED,
READY,
STARTED,
DOORS_CLOSED,
FEES_CLAIMED
}
다음으로 공개 ledger 필드를 선언합니다:
export sealed ledger organizer: Bytes<32>;
export sealed ledger maxListSize: Uint<16>;
export sealed ledger entryFee: Uint<16>;
export ledger partyState: PartyState;
export ledger hashedPartyGoers: Set<Bytes<32>>;
export ledger checkedInParty: Set<UserAddress>;
sealed 키워드는 변경 불가능한 ledger 필드를 나타냅니다. 이 필드는 constructor 실행 중에만 값을 설정할 수 있고, constructor가 끝난 뒤에는 바꿀 수 없습니다.
Witnesses
이 컨트랙트는 witness 함수를 사용하지 않지만, witness는 circuit 안에서 private state 데이터를 수정하거나, 오프체인 연산을 제공하거나, 나눗셈처럼 Compact에 없는 기능을 구현할 때 유용합니다. witness는 Compact에서 선언만 하고 구현은 오프체인 TypeScript 코드에서 하므로, witness의 반환값은 사전 검증 없이는 신뢰할 수 없습니다. witness 함수의 예시는 Battleship 튜토리얼을 참고하세요.
이 튜토리얼에서는 private state 데이터에 Compact circuit 입력으로 접근합니다.
Constructor
다음으로 컨트랙트 배포 시점에 실행되는 constructor를 작성합니다. 이 컨트랙트는 organizer가 배포합니다.
먼저 입력값이 0이 아닌지 검증한 뒤, organizer의 고유 식별자를 만듭니다:
constructor (partySize: Uint<16>, fee: Uint<16>, _secret: Bytes<32>) {
assert(partySize > 0, "The party size must be greater than zero");
assert(fee > 0, "Fee must be greater than zero");
const pubKey = getDappPublicKey(_secret);
organizer = disclose(pubKey);
}// constructor 끝
getDappPublicKey()의 구현은 뒤에서 다룹니다. 지금은 organizer가 제공한 secret을 해싱해 이 DApp 전용 "public key"를 만든다는 것만 이해하면 됩니다. Compact circuit 입력은 비공개이므로, _secret은 private state에서 circuit 입력으로 곧바로 전달되며 외부에 노출되지 않습니다. pubKey는 organizer의 주소나 다른 식별 정보와 연결되지 않으므로 ledger에 공개로 저장해도 안전합니다.
입력을 검증하고 organizer를 비공개로 식별했으니, 이제 나머지 ledger 필드를 설정합니다:
entryFee = disclose(fee);
maxListSize = disclose(partySize);
partyState = PartyState.NOT_STARTED;
}// constructor 끝
파티가 시작되기 전에 참석자는 참석 의사를 RSVP로 알려야 합니다. 이를 비공개로 처리하는 circuit을 다음과 같이 구현합니다:
export circuit rsvp(_address: UserAddress, _secret: Bytes<32>): [] {
const pubKey = getDappPublicKey(_secret);
// 호출자 인증 검사
assert(pubKey != organizer, "Organizer cannot RSVP to the party");
// 상태 검증 검사
assert(partyState == PartyState.NOT_STARTED, "The party has already started");
assert(hashedPartyGoers.size() < maxListSize, "The list is full");
}// rsvp 끝
getDappPublicKey() circuit은 organizer와 참석자 모두가 privacy를 유지하는 고유 식별자를 만드는 데 그대로 사용할 수 있습니다. 이 circuit에 전달하는 _secret이 다르면 반환되는 해시도 달라집니다.
organizer가 자기 파티에 참석하려는 것이 아닌지 assert로 확인한 다음, 컨트랙트가 RSVP를 받을 수 있는 올바른 상태인지 검증합니다.
다음으로 호출자가 제공한 UserAddress에 대한 commitment를 만듭니다:
// 참석자 address는 비공개로 유지됩니다
const commitHash = commitAddress(_secret, _address.bytes);
assert(!hashedPartyGoers.member(commitHash), "You are already on the list");
hashedPartyGoers.insert(commitHash);// persistentCommit이므로 disclose 불필요
}// rsvp 끝
이 패턴에서 circuit 호출자는 제공한 주소를 자신의 secret과 묶어 이 값들에 대한 암호학적 commitment를 만듭니다. commitAddress() circuit은 뒤에서 작성하는데, 이 circuit을 사용하면 원본 값들의 privacy를 유지하면서 해시는 공개로 저장할 수 있고, 나중에 이 값들을 다시 확인할 수도 있습니다.
이 circuit에서 마지막으로 할 일은 리스트가 가득 찼을 때 자동으로 상태를 전환하는 것입니다:
if (hashedPartyGoers.size() == maxListSize) {
partyState = PartyState.READY;
}
}// rsvp 끝
RSVP circuit이 동작하게 되었으니, 이제 organizer가 파티를 시작할 수 있는 기능을 추가합니다:
export circuit startParty(_secret: Bytes<32>): [] {
const pubKey = getDappPublicKey(_secret);
assert(organizer == pubKey, "Only the organizer can start the party");
assert(partyState == PartyState.READY || partyState == PartyState.NOT_STARTED,
"The party is not in the correct state for this operation");
partyState = PartyState.STARTED;
}// startParty 끝
이 circuit은 호출자의 pubKey를 식별하고, 앞서 저장한 organizer의 public key와 일치하는지 확인한 뒤, 컨트랙트 상태가 두 가지 허용 값 중 하나인지 검사하고 나서 상태를 갱신합니다. 컨트랙트 상태가 바뀌면 참석자가 파티에 check-in할 수 있습니다.
checkIn circuit을 작성합니다:
export circuit checkIn(address: UserAddress, _secret: Bytes<32>): [] {
// 상태 검증 검사
assert(partyState == PartyState.STARTED, "The party has not been started. Call the party police");
assert(checkedInParty.size() < hashedPartyGoers.size(), "All guests have already checked in");
const commitHash = commitAddress(_secret, address.bytes);
// 호출자 검증 검사
assert(hashedPartyGoers.member(commitHash), "You are not on the list");
assert(!checkedInParty.member(disclose(address)), "You have already checked in");
}// checkIn 끝
이 circuit은 이미 RSVP한 참석자만 호출하도록 만들어졌으며, hashedPartyGoers 리스트에 있는 사람으로 접근을 제한합니다. 호출자가 이 리스트에 없거나 RSVP 때와 다른 address 또는 _secret을 제공하면, 그 호출은 호출자 검증을 통과하지 못합니다.
commitAddress() circuit은 단방향 결정론적 해시 함수를 사용합니다. 호출자가 이전에 저장된 그 호출자임을 증명하려면 같은 값을 다시 제공해야 하고, 이 값들이 다시 해싱됩니다. 호출자가 같은 값을 제공했다면 두 해시는 일치합니다.
호출자를 식별했고 상태도 기대한 대로이니, 이제 컨트랙트가 결제를 받을 차례입니다. 파티 입장료는 NIGHT으로 결제하며, NIGHT은 Unshielded token입니다. Unshielded token 거래는 모두 공개됩니다.
이 시점에 비공개였던 참석자가 공개됩니다:
// Unshielded 결제를 받는 순간 참석자는 공개됩니다
receiveUnshielded(nativeToken(), entryFee as Uint<128>);
checkedInParty.insert(disclose(address));
}// checkIn 끝
nativeToken() 함수는 모두 0으로 이루어진 기본 color를 반환하며, 이는 NIGHT token을 가리킵니다. 결제 트랜잭션이 완료되면 address를 disclose() 처리해 ledger에 공개로 기록합니다.
disclose() 자체는 값을 공개로 만들지 않습니다. DApp 개발자인 여러분이 이 값을 공개 저장해도 안전하다고 표시한다는 사실을 컴파일러에 알려 줄 뿐입니다.
이 circuit에서 마지막으로 할 일은 모두가 check-in했을 때 자동으로 파티 입장을 마감하는 것입니다:
if(checkedInParty.size() == maxListSize) {
partyState = PartyState.DOORS_CLOSED;
}
}// checkIn 끝
maxListSize에 끝내 도달하지 못하는 경우를 대비해, organizer가 직접 이 상태를 바꿀 수 있는 circuit도 따로 제공해야 합니다:
export circuit closeEntry(_secret: Bytes<32>): [] {
const pubKey = getDappPublicKey(_secret);
assert(organizer == pubKey, "Only organizer can close the doors");
assert(partyState == PartyState.STARTED, "Party in wrong state");
partyState = PartyState.DOORS_CLOSED;
}// closeEntry 끝
컨트랙트는 받은 NIGHT token을 organizer가 청구할 때까지 보관합니다. 이제 organizer가 이 입장료를 청구할 수 있는 circuit이 필요합니다:
export circuit claimFees(address: UserAddress, _secret: Bytes<32>): [] {
const pubKey = getDappPublicKey(_secret);
assert(organizer == pubKey, "You are not the organizer");
// 상태 검증 검사
assert(partyState == PartyState.DOORS_CLOSED, "The doors are not yet closed");
assert(checkedInParty.size() > 0, "No fees to claim");
// contract의 NIGHT token 잔액 계산
const totalCollected = checkedInParty.size() * entryFee;
assert(unshieldedBalanceGte(nativeToken(), totalCollected), "Contract balance wrong");
// organizer에게 전송, organizer는 이제 공개됩니다
sendUnshielded(
nativeToken(),
disclose(totalCollected) as Uint<128>,
right<ContractAddress, UserAddress>(disclose(address))
);
partyState = PartyState.FEES_CLAIMED;
}
이 circuit은 앞의 circuit들과 구성이 비슷하지만, 토큰을 organizer에게 보내기 전에 사용 가능한 잔액을 계산한다는 점이 다릅니다. sendUnshielded() 함수를 거치면서 organizer도 공개됩니다.
unshieldedBalanceGte()로 컨트랙트 잔액이 기대한 값인지 확인하는 일은 중요합니다. 잔액이 sendUnshielded() 트랜잭션을 수행하기에 부족하면 circuit 실행이 실패합니다.
이제 commitAddress() circuit입니다:
circuit commitAddress(_address: Bytes<32>, _secret: Bytes<32>): Bytes<32> {
return persistentCommit<Bytes<32>>(_address, _secret);
}
이 circuit은 persistentCommit으로 _address를 가립니다. _address는 무작위 salt 값, 여기서는 _secret과 함께 해싱됩니다. persistentCommit의 입력은 쉽게 추측할 수 있는 단순한 값일 수 있으므로, 충분히 무작위적인 salt 값을 항상 사용해야 합니다. salt가 충분히 무작위적이라면 반환된 해시는 공개 영역으로 넘어가기 전에 disclose()를 거칠 필요가 없습니다. 이미 안전하다고 간주되기 때문입니다.
이제 getDappPublicKey() circuit입니다:
circuit getDappPublicKey(_secret: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "private-party:pk:"), _secret]);
}
이와 달리 persistentHash 함수는 _secret 같은 임의의 이진 데이터를 위한 것입니다. 이 DApp 전용 domain separator와 함께 해싱하면 "DApp 전용 public key"가 만들어지며, 이 값을 공개 영역으로 넘기려면 disclose해야 합니다. 현재로서는 이것이 Compact circuit의 호출자를 검증하는 유일하게 안전한 방법입니다.
private party에 필요한 Compact 코드는 이것이 전부입니다!
Compilation
컨트랙트를 컴파일하려면:
yarn compile
성공 시 출력:
$ compact compile contract/private-party.compact contract/managed/private-party
Compiling 5 circuits:
circuit "checkIn" (k=13, rows=4530)
circuit "claimFees" (k=13, rows=4512)
circuit "closeEntry" (k=13, rows=4203)
circuit "rsvp" (k=14, rows=8423)
circuit "startParty" (k=13, rows=4232)
Done in 8.24s.
컴파일이 실패하면 컴파일러 오류 메시지를 꼼꼼히 읽고 이해한 뒤 오류를 수정하세요. 컴파일러와 씨름하는 과정은 새 언어를 배우는 가장 좋은 방법 중 하나입니다.
circuit 데이터를 더 자세히 살펴보려면 /contract 디렉터리에서 zkir linter를 실행하세요(선택사항):
npx compact-zkir-lint -r managed/private-party/zkir
성공 시 출력:
zkir-lint: scanned 5 file(s)
checkIn (v2, k=11): clean
instructions: 258 inputs: 4 constrain_bits: 4 cond_select: 6
guarded regions: 0 (max depth 0) proof payload: ~96KB
claimFees (v2, k=11): clean
instructions: 303 inputs: 4 constrain_bits: 4 cond_select: 2
guarded regions: 0 (max depth 0) proof payload: ~96KB
closeEntry (v2, k=11): clean
instructions: 63 inputs: 2 constrain_bits: 2 cond_select: 1
guarded regions: 0 (max depth 0) proof payload: ~96KB
rsvp (v2, k=12): clean
instructions: 179 inputs: 4 constrain_bits: 4 cond_select: 7
guarded regions: 0 (max depth 0) proof payload: ~192KB
startParty (v2, k=11): clean
instructions: 84 inputs: 2 constrain_bits: 2 cond_select: 8
guarded regions: 0 (max depth 1) proof payload: ~96KB
0 error(s), 0 warning(s), 0 info(s) | 5/5 clean
Testing
컴파일을 통과했다는 것은 Compact 컴파일러가 보기에 문법이 올바르다는 뜻일 뿐입니다. 컨트랙트가 기대대로 동작하는지 확인하려면 테스트 스위트를 실행하세요. 안전한 컨트랙트를 작성하려면 포괄적인 테스트 스위트가 필수입니다.
Docker 엔진이 실행 중인지 확인하고 로컬 devnet을 시작하세요:
yarn env:up
이 명령은 Docker 컨테이너 안에 다음으로 구성된 작은 로컬 블록체인을 만듭니다:
- Midnight Node
- Midnight Indexer
- Midnight proof server
proof server는 Midnight의 영지식 증명 시스템에서 핵심적인 구성 요소입니다. 자세한 내용은 Zero-knowledge proofs를 참고하세요.
테스트 스위트를 실행합니다:
yarn test:local
성공 시 출력:
[17:27:12.796] INFO (46073): Wallet sync [22]: shielded=true, unshielded=true, dust=true
[17:27:12.796] INFO (46073): Wallet sync complete after 22 emissions
[17:27:12.800] INFO (46073): Providers initialized. Ready to test.
[17:27:12.800] INFO (46073): Bob providers successfully initialized
[17:27:12.801] INFO (46073): Claire providers successfully initialized
[17:27:12.802] INFO (46073): Deploying a contract the easy way...
[17:27:32.867] INFO (46073): Contract deployed at 7da6acdcd5792da7f7278fb5362ec61b35e6693763bb3c7459720ef7945287e8
[17:27:33.013] INFO (46073): Bob is sending an RSVP...
[17:27:51.082] INFO (46073): Bob rsvp'd successfully!
[17:27:51.158] INFO (46073): Alice tries to rsvp...
[17:27:51.232] INFO (46073): Alice was rejected!
[17:27:51.373] INFO (46073): Claire is attempting to rsvp...
[17:28:08.412] INFO (46073): Claire successfully rsvp'd!
[17:28:08.492] INFO (46073): Bob tries to start the party...
[17:28:08.567] INFO (46073): Bob was rejected!
[17:28:08.636] INFO (46073): Alice starts the party...
[17:28:26.688] INFO (46073): Alice started the party successfully!
[17:28:26.761] INFO (46073): Bob is checking in...
[17:28:44.976] INFO (46073): Bob has successfully checked in and is now public!
[17:28:45.055] INFO (46073): Bob is attempting to close the doors...
[17:28:45.128] INFO (46073): Bob was rejected!
[17:28:45.196] INFO (46073): Alice is closing the doors...
[17:29:02.303] INFO (46073): Alice has successfully closed the doors!
[17:29:02.381] INFO (46073): Alice NIGHT balance before claimFees: 250000000000000
[17:29:02.381] INFO (46073): Alice is claiming fees...
[17:29:21.979] INFO (46073): Alice has successfully claimed fees!
[17:29:45.155] INFO (46073): Alice NIGHT balance after claimFees: 250000000000005
[17:29:45.155] INFO (46073): Alice NIGHT balance delta: 5
[17:29:45.177] INFO (46073): Unproven tx created. Pending contract address: 33b39d93fcd9b1c06df5d44020776b291cbb356bd40b75e1e928eb9e41f15656
[17:29:45.178] INFO (46073): proven tx received from proof server
[17:29:46.082] INFO (46073): Balanced tx ready for submission
[17:30:01.943] INFO (46073): Submitted tx id: 004081caf449e3cdd86f9c0abfac77b06e9b635ae9e6b5954c10200f8d6eac3ace
[17:30:02.952] INFO (46073): Finalized! Status: SucceedEntirely, block: 31
✓ src/test/party.test.ts (11 tests) 166950ms
✓ Private Party smart contract via midnight-js > Deploys a contract (the easy way) 20076ms
✓ Private Party smart contract via midnight-js > Allows Bob to rsvp (privately) 17232ms
✓ Private Party smart contract via midnight-js > Blocks organizers from rsvp 145ms
✓ Private Party smart contract via midnight-js > Allows Claire to rsvp(privately) 17184ms
✓ Private Party smart contract via midnight-js > Blocks non-organizers from starting the party 154ms
✓ Private Party smart contract via midnight-js > starts the party 17177ms
✓ Private Party smart contract via midnight-js > Allows Bob to check in 17338ms
✓ Private Party smart contract via midnight-js > Blocks non-organizers from closing the doors 150ms
✓ Private Party smart contract via midnight-js > Closes the doors to the party 17175ms
✓ Private Party smart contract via midnight-js > Allows Alice to claimFees 41849ms
✓ Private Party smart contract via midnight-js > Deploys the contract(the hard way) 16823ms
이 테스트를 구동하는 MidnightJS 구성 요소는 /src/test/party.test.ts에서 읽어 보세요. 테스트에 대해 더 알아보려면 Test and debug를 참고하세요.
Conclusion
이것으로 private party 튜토리얼을 마칩니다. Unshielded NIGHT token을 사용하면서 참석자가 공개되었습니다. 참석자의 privacy를 끝까지 유지하고 싶다면, 이 컨트랙트와 테스트 스위트를 Shielded token을 사용하도록 고쳐 보세요.
전체 private-party 저장소는 example-private-party에서 확인할 수 있습니다. 질문이 있다면 Discord의 dev-chat 채널에 남겨 주세요.
Next steps
처음부터 끝까지 직접 구축하는 더 포괄적인 튜토리얼이 필요하다면 Battleship을, 더 고급 사례로 넘어가려면 ZK-loan을 참고하세요.