For the complete documentation index, see llms.txt
Private party contract
이 장은 contract/private-party.compact를 circuit 하나씩, 파티가 진행되는 순서대로 따라갑니다. 이 컨트랙트에는 witness가 없으므로, DApp의 온체인 로직 전체가 이 파일 하나에 담겨 있습니다.
Prerequisites
- 이 튜토리얼의 설정 단계 — midnight-examples를 설치하고 컴파일해 둔 상태여야 합니다.
- Compact toolchain과 Node.js 22.
에디터에서 examples/private-party/contract/private-party.compact를 열어 이 페이지 옆에 두고 보세요.
The party
organizer는 참석자 명단을 비공개로 유지하면서 파티 RSVP를 모으려 합니다. 참석자는 도착할 때 입장료를 NIGHT으로 내고, organizer는 그 뒤에 입장료를 모아 갑니다.
순서대로, 컨트랙트가 해야 할 일은 다음과 같습니다:
- organizer가 파티 규모와 입장료를 정해 컨트랙트를 배포한다.
- 참석자가 비공개로 RSVP한다.
- organizer만, 오직 organizer만 파티를 시작한다.
- RSVP한 참석자가 check-in하고 입장료를 낸다.
- organizer가 입장을 마감한다.
- 모은 입장료를 organizer에게 지급한다.
The privacy boundary
Compact에서 데이터는 기본적으로 비공개입니다. DApp 개발자인 여러분이 무엇을 비공개에서 공개로 넘길지 정하고, 컴파일러는 그 전환이 일어나는 자리를 표시하도록 요구합니다. 다만 일부는 항상 공개입니다:
- ledger 필드, 그리고 circuit이 거기에서 읽거나 거기에 쓰는 값;
- unshielded token 전송;
- 트랜잭션이 호출하는 circuit.
이 목록에는 export된 circuit의 반환값이 없습니다. 반환값은 circuit을 호출한 코드로 돌아갑니다. 트랜잭션에는 그 호출의 입력과 출력에 대한 무작위화된 commitment만 실립니다.
해시와 commitment를 쓰면 데이터를 드러내지 않고도 ledger 필드에 넣을 수 있습니다. unshielded 전송은 그런 식으로 가릴 수 없습니다. 보내는 쪽, 받는 쪽, 금액이 모두 공개됩니다.
그래서 NIGHT 입장료가 이 DApp의 privacy boundary가 됩니다. 참석자는 check-in에서 입장료를 낼 때까지 비공개이고, organizer는 컨트랙트가 입장료를 지급할 때까지 비공개입니다.
| 공개 (ledger 또는 트랜잭션에 드러남) | 비공개 (사용자 기기를 벗어나지 않음) |
|---|---|
| organizer의 DApp 전용 public key | organizer의 secret |
| RSVP마다 만들어지는 commitment | 각 참석자의 secret, 그리고 check-in 전까지의 주소 |
| check-in한 참석자의 주소 | |
| 파티 규모, 입장료, 파티 상태 | |
| 오가는 각 NIGHT 결제 |
The state machine
모든 export된 circuit은 누구나 언제든 호출할 수 있는 공개 진입점입니다. 컨트랙트는 enum으로 파티의 각 단계에서 어떤 circuit이 열려 있는지 나타냅니다:
export enum PartyState {
NOT_STARTED,
READY,
STARTED,
DOORS_CLOSED,
FEES_CLAIMED
}
각 circuit은 다른 일을 하기 전에 먼저 assert로 현재 상태를 확인하고, 일부는 파티를 다음 상태로 옮깁니다:
| From | Circuit | To |
|---|---|---|
NOT_STARTED | rsvp가 명단을 채움 | READY |
NOT_STARTED 또는 READY | startParty (organizer) | STARTED |
STARTED | checkIn이 가득 찬 명단의 마지막 참석자를 check-in | DOORS_CLOSED |
STARTED | closeEntry (organizer) | DOORS_CLOSED |
DOORS_CLOSED | claimFees (organizer) | FEES_CLAIMED |
The ledger
컨트랙트의 공개 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 필드는 constructor만 설정할 수 있습니다. 배포 후에는 organizer, 파티 규모, 입장료를 어떤 것도 바꿀 수 없습니다.
hashedPartyGoers는 RSVP 명단입니다. 주소가 아니라 commitment를 담습니다. checkedInParty는 입장료를 낸 참석자의 주소를 담는데, 그 시점이면 어차피 공개이기 때문입니다.
Private state: a secret, and no witnesses
사용자는 organizer든 참석자든 각자 비공개 값 하나, 즉 32바이트 secret을 가집니다. 이 값은 contract/witnesses.ts의 private state에 있습니다:
export type PartyPrivateState = {
secret: Uint8Array,
}
export const createPartyPrivateState = (secret: Uint8Array) => ({
secret
});
많은 컨트랙트는 witness 함수로 private state를 읽습니다. 이 컨트랙트에는 witness가 필요 없습니다. 호출자가 secret을 circuit 인자로 곧바로 전달합니다. circuit 인자는 증명의 입력이며 공개되지 않으므로, secret은 호출자 기기를 벗어나지 않습니다. circuit이 그 값으로 만든 것, 즉 해시나 commitment만 ledger에 도달할 수 있습니다.
witness가 동작하는 모습은 Battleship 튜토리얼을 참고하세요.
Identity: a DApp-specific public key
컨트랙트는 organizer를 지갑으로 식별하지 않습니다. 자신이 안다고 증명할 수 있는 secret으로 식별하며, 그 secret을 이 DApp에서만 쓰는 public key로 해싱합니다:
// hash a publicKey specific to this DApp so that users cannot be tracked
// the _secret should be 32 random bytes: anyone who can guess it holds the key
circuit getDappPublicKey(_secret: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "private-party:pk:"), _secret]);
}
persistentHash는 단방향 해시이므로 이 키는 secret을 드러내지 않습니다. "private-party:pk:" 접두사는 domain separator입니다. 덕분에 같은 secret을 해싱한 다른 어떤 값과도, 심지어 다른 DApp이 키로 쓸 수도 있는 해시와도 이 키가 달라집니다. 이 키는 organizer의 지갑 주소와 묶이지 않으므로 ledger에 저장해도 안전합니다.
organizer가 컨트랙트를 배포합니다. constructor는 입력을 확인하고, 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);
entryFee = disclose(fee);
maxListSize = disclose(partySize);
partyState = PartyState.NOT_STARTED;
}
_secret 인자는 비공개입니다. pubKey는 여기서 파생되므로, 컴파일러는 이 값을 ledger에 쓰기 전에 disclose()를 요구합니다. constructor 인자인 fee와 partySize도 마찬가지입니다.
disclose()는 그 자체로 값을 공개로 만들지 않습니다. 비공개 입력에서 파생된 값이 circuit을 벗어나는 것을 개발자인 여러분이 받아들인다고 컴파일러에 알려 줄 뿐입니다. 그 값은 circuit이 ledger를 읽거나 쓸 때, 또는 unshielded 전송 같은 공개 작업에 쓸 때 공개됩니다.
RSVP: a salted commitment
참석자는 자신의 주소와 secret으로 rsvp를 호출합니다:
// called by party goers
export circuit rsvp(_address: UserAddress, _secret: Bytes<32>): [] {
const pubKey = getDappPublicKey(_secret);
// caller authentication check
assert(pubKey != organizer, "Organizer cannot RSVP to the party");
// state verification check
assert(partyState == PartyState.NOT_STARTED, "The party has already started");
assert(hashedPartyGoers.size() < maxListSize, "The list is full");
organizer는 적어도 자신의 organizer secret으로는 자기 파티에 RSVP할 수 없습니다. RSVP는 파티가 시작되기 전에만, 그리고 명단에 자리가 있을 때만 열려 있습니다.
그런 다음 참석자는 commitment 형태로 명단에 오릅니다:
// party goer address remains private
const commitHash = commitAddress(_secret, _address.bytes);
assert(!hashedPartyGoers.member(commitHash), "You are already on the list");
hashedPartyGoers.insert(commitHash);// doesn't need disclose bc persistentCommit
commitAddress는 한 줄짜리 헬퍼입니다:
// commit to the guest's address, salted with their secret: the commitment
// hides the address, and only someone holding the same secret can recompute it
circuit commitAddress(_secret: Bytes<32>, _address: Bytes<32>): Bytes<32> {
return persistentCommit<Bytes<32>>(_address, _secret);
}
persistentCommit(value, rand)은 rand를 무작위값으로 삼아 value에 commit합니다. 여기서 value는 참석자의 주소이고 무작위값은 참석자의 secret입니다.
주소를 그냥 해싱하면 주소가 가려지지 않습니다. 주소는 비밀이 아니므로, 누구든 자신이 아는 모든 주소를 해싱해 명단과 대조할 수 있습니다. salt가 이를 막습니다. 참석자의 secret 없이는 아무도 commitment를 다시 계산할 수 없으므로, 명단은 누가 올라 있는지 전혀 드러내지 않습니다. 컴파일러가 disclose() 없이도 commitment를 ledger에 받아들이는 이유도 여기에 있습니다. 컴파일러는 무작위값이 정말로 무작위라는 전제 아래, commitment가 아무것도 드러내지 않는 것으로 취급합니다. 그 전제를 지키는 일은 여러분의 몫입니다. 참석자마다 secret은 무작위 32바이트여야 합니다.
명단이 가득 차면 파티는 READY로 넘어갑니다:
if (hashedPartyGoers.size() == maxListSize) {
// @TODO -- In the future, emit an event to the organizer here (MIP-0002)
partyState = PartyState.READY;
}
}
Starting the party
organizer만 파티를 시작할 수 있습니다:
// start the party (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;
}
이는 organizer 전용 circuit마다 쓰이는 접근 제어 패턴입니다. 호출자가 secret을 전달하면 circuit이 이를 해싱하고, 그 결과가 배포 때 저장한 organizer 키와 일치해야 합니다. organizer의 secret 보유자만 이 assert를 통과하는 증명을 만들 수 있습니다.
organizer는 명단이 가득 차기 전 NOT_STARTED에서, 또는 가득 찬 뒤 READY에서 파티를 시작할 수 있습니다.
Check-in: crossing the boundary
참석자는 RSVP 때 쓴 주소와 secret 그대로 check-in합니다:
// called by the party goer, so the payment can be prompted to the caller
// after the execution of this circuit, party goers are public
export circuit checkIn(address: UserAddress, _secret: Bytes<32>): [] {
// state verification checks
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);
// caller verification checks
assert(hashedPartyGoers.member(commitHash), "You are not on the list");
assert(!checkedInParty.member(disclose(address)), "You have already checked in");
circuit은 인자로부터 commitment를 다시 계산합니다. persistentCommit은 결정론적이므로, 같은 주소와 secret은 RSVP 때와 동일한 commitment를 만듭니다. 다른 주소나 secret을 준 호출자는 다른 commitment를 만들고, 그 값은 명단에 없습니다.
그런 다음 참석자는 입장료를 내고 공개됩니다:
// take in unshielded payment, party goers are now public
receiveUnshielded(nativeToken(), entryFee as Uint<128>);
checkedInParty.insert(disclose(address));
nativeToken()은 NIGHT의 token 유형입니다. receiveUnshielded는 트랜잭션이 entryFee를 컨트랙트에 내도록 하며, 단위는 NIGHT의 최소 단위인 STAR입니다(1 NIGHT = 1,000,000 STAR). 이 자금은 호출자의 지갑이 트랜잭션 잔액을 맞출 때 공급합니다. 이는 unshielded 전송이므로 공개됩니다. 참석자의 주소도 checkedInParty에 올라가며, 그래서 disclose()가 필요합니다.
여기가 privacy boundary입니다. 여기까지는 온체인의 어떤 것도 참석자를 파티와 연결하지 않았습니다. 이제 참석자의 주소가 공개되고, 그 참석자의 RSVP로 이어지는 연결도 공개됩니다. 멤버십을 확인하려고 circuit은 commitHash로 hashedPartyGoers를 읽는데, ledger 읽기는 ledger 쓰기와 마찬가지로 공개입니다. 방금 입장료를 낸 주소가 명단의 어느 commitment에 해당하는지 누구나 볼 수 있습니다.
가득 찬 명단의 마지막 참석자가 check-in하면 입장이 저절로 마감됩니다:
if(checkedInParty.size() == maxListSize) {
partyState = PartyState.DOORS_CLOSED;
}
}
Closing the doors
명단이 끝내 가득 차지 않거나 참석자가 모두 오지는 않는 경우, organizer가 직접 입장을 마감합니다:
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;
}
Claiming the fees
이제 컨트랙트가 입장료를 NIGHT으로 보관하고 있습니다. organizer가 이를 청구하며, 그 과정에서 지급받을 주소를 제시합니다:
export circuit claimFees(address: UserAddress, _secret: Bytes<32>): [] {
const pubKey = getDappPublicKey(_secret);
assert(organizer == pubKey, "You are not the organizer");
// state verification checks
assert(partyState == PartyState.DOORS_CLOSED, "The doors are not yet closed");
assert(checkedInParty.size() > 0, "No fees to claim");
// calculate contract balance of NIGHT tokens
const totalCollected = checkedInParty.size() * entryFee;
assert(unshieldedBalanceGte(nativeToken(), totalCollected), "Contract balance wrong");
// send to organizer, they are now public
sendUnshielded(
nativeToken(),
disclose(totalCollected) as Uint<128>,
right<ContractAddress, UserAddress>(disclose(address))
);
partyState = PartyState.FEES_CLAIMED;
}
circuit은 자신이 보관해야 할 금액, 즉 check-in한 참석자 1명당 입장료 하나를 계산하고, 무엇이든 보내기 전에 unshieldedBalanceGte로 NIGHT 잔액이 그 금액을 감당하는지 확인합니다.
sendUnshielded는 그 금액을 수신자에게 지급합니다. 수신자는 Either입니다. 왼쪽은 contract address, 오른쪽은 user address이며, right<ContractAddress, UserAddress>가 만드는 것이 바로 오른쪽 값입니다. 이 지급은 unshielded 전송이므로, organizer의 주소가 여기서 공개됩니다. organizer 자신의 privacy boundary인 셈입니다.
What this contract doesn't stop
이 컨트랙트는 privacy boundary를 가르치기 위한 예제일 뿐, 보안을 강화한 DApp이 아닙니다. 다시 가져다 쓰기 전에 무엇이 열려 있는지 알아 두세요:
- 한 사람이 여러 번 RSVP할 수 있습니다. 새 secret마다 새 commitment가 나오므로, 한 참석자가 자기 RSVP로 명단을 채워 버릴 수 있습니다.
- organizer가 두 번째 secret으로 RSVP할 수 있습니다.
pubKey != organizer검사는 organizer 자신의 키만 막습니다. - check-in은 참석자를 자신의 RSVP와 연결합니다. 앞서 본 대로 check-in은 참석자의 주소와 그 주소가 맞춘 commitment를 공개합니다. RSVP 명단은 아직 check-in하지 않은 참석자에 한해서만 비공개로 남습니다.
- 주소를 호출자와 묶는 장치가 없습니다.
rsvp와checkIn은 주소를 인자로 받고, 입장료는 트랜잭션 잔액을 맞추는 지갑이 무엇이든 거기서 나옵니다. 참석자는 남의 주소로 RSVP하고 check-in할 수 있습니다. 그러면checkedInParty에는 누가 냈는지가 아니라 참석자가 제시한 주소가 올라가고, 그 주소의 실제 소유자는 더 이상 check-in할 수 없게 됩니다. - organizer의 키는 어느 파티에서나 같습니다. 키는 secret만 해싱한 값이므로, 여러 배포에 걸쳐 secret을 재사용하는 organizer는 그 모든 배포에서 식별됩니다. 컨트랙트 자신의 주소인
kernel.self()를 해시에 더하면 배포마다 키가 달라집니다. - 활동은 공개입니다. 각 트랜잭션이 어떤 circuit을 호출하는지는 공개이므로, 누가 언제 RSVP하고 명단에 참석자가 몇 명 있는지는 누구나 볼 수 있습니다. 다만 그들이 누구인지는 알 수 없습니다.
학습 경로에서 다음에 이어지는 Battleship 튜토리얼은 컨트랙트를 방어하는 방법을 더 깊이 다룹니다. 상태 머신, 역할 검사, 그리고 모든 가정에 대한 assertion입니다.
Compile
examples/private-party에서:
yarn compile
이 명령은 compact compile contract/private-party.compact contract/managed/private-party를 실행하고 proving key를 생성합니다:
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)
Overall progress [====================] 5/5
circuit 다섯 개로, export된 circuit마다 하나씩입니다. commitAddress와 getDappPublicKey는 export되지 않으므로 자체 proving key가 없습니다. 이들은 자신을 호출하는 circuit 안에 함께 컴파일됩니다.
컴파일이 실패하면 컴파일러의 오류 메시지를 꼼꼼히 읽으세요. 메시지는 해당 줄을 알려 주고 대개 해결 방법까지 알려 줍니다.
Next
Private party tests에서 MidnightJS로 로컬 devnet에서 파티를 실행해 봅니다.