Manage private state with witnesses
For the complete documentation index, see llms.txt
Midnight Network에서는 영지식(ZK) 증명 덕분에 검증 가능한 상태를 유지하면서도 데이터를 private로 지킬 수 있습니다. Midnight DApp을 만들 때 핵심 개념은 witness입니다. witness는 증명하는 사용자는 알지만 검증하는 네트워크에게는 숨겨진 데이터를 공급하는 함수입니다.
이 가이드는 Compact에서 witness를 선언하고, 컴파일러가 생성한 모듈에 맞춰 TypeScript로 구현하고, 흔한 문제를 해결하는 방법을 다룹니다.
Prerequisites
- Compact 개발자 도구가 설치되어 있고 컴파일러 0.31.1이 기본값으로 설정되어 있어야 합니다. Update the compiler version에서 버전을 고정하는 방법을 보여 줍니다.
@midnight-ntwrk/compact-runtime@0.16.0이 설치된 Node.js 프로젝트가 필요합니다. 컴파일러 0.31.1과 runtime 0.16.0은 호환성 매트릭스의 짝이며, 이 페이지의 예제는 그 짝을 대상으로 합니다. 생성된 코드는 import할 때 runtime 버전을 확인하므로, 짝이 맞지 않으면Version mismatch오류로 실패합니다.- 컨트랙트를 컴파일하고 그 생성된 모듈을 import하는 데 익숙해야 합니다. Using Compact contracts from JavaScript가 이를 처음부터 끝까지 다룹니다.
Core concepts
코드로 들어가기 전에, Midnight DApp에서 witness의 역할을 이해해 두면 도움이 됩니다:
- Public state (ledger): Midnight 블록체인에 저장되어 누구나 읽을 수 있는 데이터.
- Private state: 사용자의 DApp이 로컬에 보관하며 네트워크로는 절대 전송되지 않는 데이터. TypeScript 코드가 그 형태를 정의하고 최신 상태로 유지합니다.
- Witness: Compact에서 선언하고 TypeScript로 구현하는 함수로, 로컬 실행 중에 스마트 컨트랙트 circuit에 private 데이터를 공급합니다. circuit은 이 데이터로 ZK 증명을 생성하고, DApp이 이를 네트워크에 제출합니다. 네트워크는 witness 데이터를 전혀 보지 않고 증명을 검증합니다.
private state와 witness는 관련은 있지만 구분됩니다. witness는 보통 private state를 읽고(경우에 따라 갱신하고), witness 값은 신뢰할 수 없는 상태로 들어오므로, 컨트랙트가 의존하는 속성은 circuit이 반드시 assert해야 합니다.
Step-by-step walkthrough: a private access code
사용자가 public 카운터를 갱신하려면 비밀 접근 코드를 제공해야 하는 circuit을 만듭니다. 네트워크는 카운터가 증가했다는 사실은 알지만, 사용된 접근 코드는 결코 알지 못합니다.
1. Define the circuit in Compact
Compact 컨트랙트에서 witness 함수를 선언합니다. 이는 circuit이 실행 중에 외부의 private 입력을 기대하며, 그 입력은 호출 시점에 TypeScript 코드가 공급한다고 컴파일러에 알립니다.
// access_control.compact
pragma language_version >= 0.16;
import CompactStandardLibrary;
export ledger counter: Uint<32>;
// The network knows the hash of the secret code, but not the code itself.
// The expected hash is set once at deployment via the constructor.
export ledger secret_hash: Bytes<32>;
// Declare the witness function
witness access_witness(): Bytes<32>;
constructor(sk: Bytes<32>) {
secret_hash = disclose(persistentHash<Bytes<32>>(sk));
}
export circuit increment_counter(): [] {
// 1. Fetch the private data via the witness
const secret_code = access_witness();
// 2. Verify the secret code matches the expected hash
// The hash function is evaluated locally, and a proof of correct execution is generated.
const computed_hash = persistentHash<Bytes<32>>(secret_code);
assert(computed_hash == secret_hash, "Invalid access code");
// 3. Update the public ledger
counter = (counter + 1) as Uint<32>;
}
2. Provide the witness in TypeScript
Compact 컴파일러가 컨트랙트를 처리하면 managed/ 디렉터리에 JavaScript 구현을 생성합니다. 생성된 Contract 클래스는 키가 .compact 파일에 선언된 witness 이름과 일치하는 witnesses 객체를 요구합니다.
각 witness 함수는 현재 ledger 뷰, private state, 컨트랙트 주소를 담은 WitnessContext를 받고, 갱신된 private state와 witness 값으로 이루어진 튜플을 반환합니다.
import * as RT from '@midnight-ntwrk/compact-runtime';
import { Contract, ledger } from './managed/access_control/contract/index.js';
import type { Ledger } from './managed/access_control/contract/index.js';
import type { WitnessContext } from '@midnight-ntwrk/compact-runtime';
// Define the shape of your private state
type AccessControlPrivateState = {
readonly secretCode: Uint8Array;
};
// Create a factory for initial private state
const createPrivateState = (secretCode: Uint8Array): AccessControlPrivateState => ({
secretCode,
});
// Implement witnesses: keys must match Compact witness names exactly
const witnesses = {
access_witness: ({
privateState,
}: WitnessContext<Ledger, AccessControlPrivateState>): [
AccessControlPrivateState,
Uint8Array,
] => [privateState, privateState.secretCode],
};
// Instantiate the contract with the witnesses object
const contract = new Contract(witnesses);
인스턴스를 만든 뒤에는 runtime 헬퍼로 circuit 컨텍스트를 구성하고 contract.impureCircuits를 통해 circuit을 호출합니다. 컨텍스트를 손으로 조립하지 말고 RT.createConstructorContext와 RT.createCircuitContext를 사용하세요. 실제 CircuitContext에는 래퍼가 검증하는 query-context 상태가 담겨 있습니다.
// Local scaffolding for running the circuit off-chain: a zeroed coin public key
// and a sample contract address. A deployed DApp gets the real values from the
// wallet and the deployment instead.
const COIN = '0'.repeat(64);
const ADDR = RT.sampleContractAddress();
const secretCode = new Uint8Array(32).fill(7); // any 32-byte secret
// 1. Create the genesis state with the constructor
const ctor = contract.initialState(
RT.createConstructorContext(createPrivateState(secretCode), COIN),
secretCode, // constructor argument: the secret key to hash and store
);
// 2. Build a circuit context from the constructor result
const ctx = RT.createCircuitContext(
ADDR, COIN, ctor.currentContractState, createPrivateState(secretCode),
);
// 3. Call the impure circuit, which invokes access_witness() automatically
const call = contract.impureCircuits.increment_counter(ctx);
// 4. Read the resulting ledger state with the generated helper
const state = ledger(call.context.currentQueryContext.state);
console.log('New counter value:', state.counter);
생성된 모듈을 import하고, witness를 작성하고, circuit을 호출하고, 테스트를 실행하는 전체 과정은 Using Compact contracts from JavaScript를 참고하세요.
Troubleshooting and best practices
witness를 다루다 보면 흔한 함정 몇 가지를 마주칠 수 있습니다.
1. Missing or misnamed witness
원인: Contract constructor는 Compact에 선언된 모든 witness가 witnesses 객체에 대응하는 함수를 갖는지 확인합니다. 하나라도 빠지거나 이름이 틀리면 constructor가 first (witnesses) argument to Contract constructor does not contain a function-valued field named access_witness를 던집니다.
해결: witnesses 객체의 모든 키가 .compact 파일에 선언된 witness 이름과 정확히 일치하는지 확인하세요. 생성된 모듈이 던지는 다른 오류는 Errors from the generated module를 참고하세요.
2. Witnesses that return a new value on every call
원인: circuit 안의 각 witness 호출은 TypeScript 함수를 한 번 실행하고, 증명은 그 호출이 반환한 값을 사용합니다. 호출마다 새로운 무작위 값을 생성하는 witness는 각 호출 지점과 이후의 각 트랜잭션에 서로 다른 값을 주며, 그 값을 아무것도 저장하지 않습니다. 그런 값 중 하나에 commit하면 나중에 commitment를 열 수 없습니다.
해결: 값이 여러 호출 지점이나 트랜잭션에 걸쳐 동일하게 유지되어야 한다면, 그 값을 한 번 생성해 private state에 저장하고, witness가 저장된 값을 반환하게 하세요.
type MyPrivateState = { readonly nonce?: Uint8Array };
// BAD: Generates a new random value every time it's called, and never saves it
const witnesses = {
get_random_nonce: ({
privateState,
}: WitnessContext<Ledger, MyPrivateState>): [MyPrivateState, Uint8Array] => [
privateState,
crypto.getRandomValues(new Uint8Array(32)),
],
};
// GOOD: Generates the value once, stores it in private state, and returns the stored value
const witnesses = {
get_random_nonce: ({
privateState,
}: WitnessContext<Ledger, MyPrivateState>): [MyPrivateState, Uint8Array] => {
const nonce = privateState.nonce ?? crypto.getRandomValues(new Uint8Array(32));
return [{ ...privateState, nonce }, nonce];
},
};
3. Failed asserts
원인: witness가 공급한 private 데이터가 circuit의 assert 문을 충족하지 못했습니다. 예를 들어 해시가 저장된 secret_hash와 일치하지 않는 비밀입니다. circuit은 증명 생성이 시작되기 전에 로컬에서 실행되므로, 호출은 그 지점에서 컨트랙트의 메시지와 함께 던져집니다: failed assert: Invalid access code.
해결: circuit을 호출하는 곳에서 오류를 잡아 UI에 이유를 보여 주세요. 호출이 증명 전에 실패하므로 잘못된 값은 증명 시간을 전혀 소모하지 않습니다. 같은 assert는 증명이 보장하는 circuit의 일부이므로, 로컬 검사를 건너뛰는 클라이언트라도 유효한 증명을 만들 수 없습니다.
Next steps
- Compact 레퍼런스에서 Compact types와 declaring witnesses를 더 알아보세요.
- 생성된 모듈의 전체 API는 Using Compact contracts from JavaScript에서 살펴보세요.
- witness 데이터를 소비하는 컨트랙트는 Security and best practices로 강화하세요.
- 네트워크에 올릴 준비가 되면, Deploy and operate a contract가 provider, 배포, 유지관리를 다룹니다.
- constructor가 저장하는 해시를 왜
disclose()로 감싸는지는 How to build a private smart contract가 프라이버시 경계를 처음부터 끝까지 다룹니다.