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

Smart contract security

Midnight의 Compact 스마트 컨트랙트는 프라이버시를 보존하는 연산과 암호학적 보장을 함께 제공합니다. 컴파일러가 일정한 규칙과 제약을 강제하지만, 컨트랙트를 안전하게 동작시키는 책임의 상당 부분은 스마트 컨트랙트를 구현하는 개발자에게 있습니다. 따라서 스마트 컨트랙트 개발자는 자주 발생하는 함정을 이해하고 보안 모범 사례를 따라야 합니다.

Security model overview

Compact는 여러 계층에 걸쳐 보안을 강제합니다.

  • Privacy by default: 비공개 데이터는 명시적으로 disclose된 뒤에야 온체인에 노출될 수 있습니다
  • Compile-time validation: 컴파일러가 witness 데이터의 의도치 않은 disclosure를 차단합니다
  • zero-knowledge proofs: 모든 circuit 연산은 입력값을 노출하지 않고 암호학적으로 검증됩니다
  • Bounded execution: 고정된 연산 한도를 둬서 자원 고갈 공격을 막습니다
  • Immutable deployments: 컨트랙트가 배포된 상태를 변조할 수 없으며, 트랜잭션은 항상 새로운 출력 상태를 생성합니다

Three execution contexts

Compact 컨트랙트는 서로 다른 세 가지 보안 컨텍스트에서 동작합니다.

  1. Public ledger: 네트워크의 모든 관찰자가 볼 수 있는 온체인 상태
  2. zero-knowledge circuits: 비공개 입력을 드러내지 않고 proof로 연산을 검증하는 온체인 함수
  3. Local computation: witness 함수를 통해 사용자 머신에서 실행되는 임의 코드

이 세 컴포넌트와 그 경계를 이해하는 것이 안전한 컨트랙트를 작성하는 핵심입니다.

Sealed vs. unsealed ledger fields

Ledger 필드는 선택적으로 sealed로 표시해서 컨트랙트 초기화 이후에는 변경할 수 없게 만들 수 있습니다. Sealed 필드는 컨트랙트 배포 시점에 constructor 또는 constructor가 호출하는 helper circuit에서만 값을 설정할 수 있습니다. 초기화 이후에는 어떤 exported circuit도 sealed 필드를 수정할 수 없습니다.

  • Unsealed fields (기본값) - 컨트랙트 실행 중 exported circuit이 자유롭게 수정할 수 있습니다
  • Sealed fields - 초기화 시에만 설정 가능하며, 이후에는 변경할 수 없습니다
sealed ledger field1: Uint<32>;
export sealed ledger field2: Uint<32>;

circuit init(x: Uint<32>): [] {
field2 = x; // Valid: called by constructor
}

constructor(x: Uint<16>) {
field1 = 2 * x; // Valid: in constructor
init(x); // Valid: helper circuit
}

export circuit modify(): [] {
field1 = 10; // ❌ Compilation error: sealed field
}

설정값, 컨트랙트 파라미터처럼 배포 후에 바뀌면 안 되는 데이터에는 sealed 필드를 사용하세요. 컴파일러가 컴파일 타임에 이를 강제하기 때문에 exported circuit에서 실수로 값을 바꾸는 일을 막아 줍니다.

Witness functions and off-chain computation

Witness는 온체인 Compact circuit에서 호출하는 오프체인 함수입니다. 이 구조 덕분에 오프체인 연산을 온체인에서 검증할 수 있습니다. Compact는 함수 선언만 가지고 있고, 실제 구현은 TypeScript 프론트엔드 쪽에 작성합니다.

// Compact declaration
witness localSecretKey(): Bytes<32>;
witness getUserBalance(): Uint<64>;

위 witness 함수의 TypeScript 구현은 다음과 같습니다.

// TypeScript implementation
export const witnesses = {
localSecretKey: ({ privateState }: WitnessContext<Ledger, PrivateState>) =>
[privateState, privateState.secretKey],

getUserBalance: ({ privateState }: WitnessContext<Ledger, PrivateState>) =>
[privateState, privateState.balance],
};
Witness security

Witness 구현은 zero-knowledge circuit 바깥에서 실행되며 암호학적으로 검증되지 않습니다. 사용자 각자가 자기 witness 구현을 제공하므로, 컨트랙트 로직은 검증 없이 witness 값을 절대 신뢰해서는 안 됩니다.

DApp 개발자는 비공개 상태에 접근해야 하는 함수를 witness로 격리하는 것이 모범 사례지만, 컴파일러가 이를 강제하지는 않습니다. "private by default" 모델이 강하게 적용되기 때문에, 비공개 상태 데이터를 circuit 입력(internal 또는 exported)에 직접 넣어도 여전히 비공개로 유지됩니다.

ownPublicKey() is a witness function

내장 함수 ownPublicKey()는 circuit를 실행하는 사용자의 Zswap 코인 공개키를 반환하지만, 기술적으로는 witness 함수입니다. ownPublicKey()는 사전 검증 없이는 신뢰할 수 없으며, 특히 호출자 검증 메커니즘 자체로 사용해서는 안 됩니다.

warning

Compact circuit에서 호출자 검증 용도로 ownPublicKey()를 사용하지 마세요. 다른 메커니즘으로 호출자가 이미 검증된 뒤에만 사용해야 합니다.

Privacy-preserving fundamentals

Compact의 프라이버시 모델은 민감한 데이터가 기본적으로 숨겨져 있어야 한다는 원칙을 따릅니다. Public ledger와 상호작용할 때 사용자 데이터를 보호하는 안전한 컨트랙트를 작성하려면, Compact의 프라이버시 동작 방식을 정확히 이해하고 있어야 합니다.

Explicit disclosure requirement

Compact는 "private by default" 모델을 강제합니다. 모든 circuit 입력값과 witness 함수에서 파생된 값은 명시적으로 disclose하지 않는 한 비공개 상태로 남습니다. 컴파일러는 비공개 데이터의 흐름을 추적하며, 다음 동작을 허용하기 전에 disclose() 래퍼를 요구합니다.

  • Public ledger 상태에 저장
  • Exported circuit에서 반환
witness secretKey(): Bytes<32>;

// value, _sk and pk are private by default
export circuit set(value: Uint<64>): [] {
const _sk = secretKey();
const pk = persistentHash(Vector<2, Bytes<32>>([pad(32, "domain"), _sk]));

// Must explicitly disclose before storing in ledger
authority = disclose(pk);
storedValue = disclose(value);
}

disclose() 없이 데이터를 공개적으로 저장하거나 exported circuit에서 반환하려고 하면 컴파일 에러가 발생합니다.

Exception: potential witness-value disclosure must be declared but is not:
witness value potentially disclosed:
the return value of witness secretKey at line 1
nature of the disclosure:
assignment to ledger field 'authority'

비공개로 유지해야 하는 식별자 이름 앞에 underscore를 붙이는 것은 암호학에서 자주 쓰이는 모범 사례로, 어떤 값이 비공개로 유지되어야 하는지 개발자가 추적하기 쉽게 해 줍니다. Compact가 강력한 내장 프라이버시 보호를 제공하긴 하지만, DApp 개발자도 세부 사항에 꼼꼼히 주의를 기울여야 합니다.

note

disclose() 자체가 값을 공개하는 것은 아닙니다. 이 값을 공개적으로 저장해도 안전하다고 컴파일러에게 알리고 private by default 메커니즘을 통과시키는 역할을 합니다.

Best practice: Place disclose() strategically

disclose()는 가능한 한 disclosure 지점에 가깝게 두세요. 그래야 여러 코드 경로를 통해 의도치 않게 노출되는 일을 막을 수 있습니다.

// ✅ Good: Disclose at the point of use
export circuit store(flag: Boolean): [] {
const secret = getSecret();
const derived = computeValue(secret); // Still private
result = disclose(flag) ? disclose(derived) : 0; // Specific explicit disclosure
}

// ❌ Bad: Early disclosure increases risk
export circuit store(flag: Boolean): [] {
const secret = disclose(getSecret()); // Disclosed too early
const derived = computeValue(secret);
result = disclose(flag) ? derived : 0;
}

Cryptographic primitives

Compact 표준 라이브러리는 해싱, commitment, 프라이버시 보존 연산을 위한 암호학적 프리미티브를 제공합니다. 안전한 스마트 컨트랙트를 만들려면 어떤 상황에 어떤 프리미티브를 써야 하는지 이해해야 합니다.

이런 해싱·commitment 함수를 활용하면, 값을 직접 노출하지 않은 상태로 hash를 온체인에 게시한 뒤 나중에 그 값에 대한 속성을 증명하거나 값 자체를 공개할 수 있습니다.

Hash functions

Compact는 보장 수준이 다른 두 가지 hash 함수를 제공합니다. 두 함수 모두 고유한 지문을 가지면서 악의적인 행위자가 쉽게 유도할 수 없는 바이너리 데이터를 해싱하는 데 가장 유용합니다.

  • transientHash<T>(value: T): Field - 임시 일관성 검사에 최적화된 circuit용 hash. 프로토콜 업그레이드 사이의 지속성은 보장되지 않습니다
  • persistentHash<T>(value: T): Bytes<32> - 상태 데이터 파생에 적합한 SHA-256 hash. 업그레이드를 거쳐도 일관성이 유지됩니다

Ledger 상태에 저장되는 값이나 인증에 쓰이는 값은 persistentHash를 사용하세요.

Commitment schemes

Commitment를 사용하면 숫자처럼 brute force 공격으로 쉽게 유도될 수 있는 값을 안전하게 해싱할 수 있습니다. Compact의 commitment 함수에는 random salt 값이 함께 들어가는데, 이 random 값을 함께 알아맞히지 않는 한 원래 값을 추측하는 것은 불가능합니다.

  • transientCommit<T>(value: T, rand: Field): Field - 임시 용도에 적합한 circuit 효율적 commitment. 프로토콜 업그레이드 사이의 지속성은 보장되지 않습니다
  • persistentCommit<T>(value: T, rand: Bytes<32>): Bytes<32> - 영구 저장에 적합한 SHA-256 기반 commitment. 업그레이드 사이에도 일관성이 유지됩니다

Commitment 스킴과 해싱 함수는 두 가지 보안 속성을 제공합니다.

  • Hiding - hash는 원본 값에 대해 어떤 정보도 드러내지 않습니다. 관찰자는 어떤 값이 commit되었는지 알 수 없습니다
  • Binding - 일단 commitment를 만들고 나면 값을 바꿀 수 없습니다. commitment는 원본 데이터에 영구적으로 묶입니다

이 함수들은 단방향이면서 결정론적입니다. hash 아래에 숨겨진 값은 영원히 가려진 채 그 자체로는 다시 드러나지 않습니다. hash나 commitment 아래의 정보를 검증하려면, 사용자가 같은 값을 제공하게 한 뒤 동일한 함수에 통과시키고 hash 결과를 비교해서 일치 여부를 확인하면 됩니다. 일치하지 않으면 사용자가 다른 값을 제공했다는 뜻입니다.

export ledger valueCommitment: Bytes<32>;

export circuit commitToValue(value: Uint<64>, rand: Bytes<32>): [] {
const commitment = persistentCommit(value, rand);
valueCommitment = commitment;
}

export circuit revealValue(value: Uint<64>, rand: Bytes<32>): [] {
const commitment = persistentCommit(value, rand);
assert(commitment == valueCommitment, "Invalid commitment opening");
// Value is now verified without prior public disclosure
}

persistentCommit 연산 결과에는 disclose()가 필요 없습니다. random salt 값을 사용했기 때문에 컴파일러가 반환값을 공개 저장에 충분히 안전한 비공개 값으로 간주하기 때문입니다. 이때 사용하는 random 값이 충분히 무작위적이고 유일해야 한다는 점이 매우 중요합니다.

Randomness reuse

Commitment 사이에 randomness를 재사용하지 마세요. 같은 random 값을 서로 다른 commitment 값에 다시 쓰면 commitment끼리 연결이 가능해져서 프라이버시가 일정 부분 깨집니다. random 값이 노출되더라도 영향이 가는 데이터는 단일 값으로 한정됩니다.

Double-spend prevention with nullifiers

Nullifier는 어떤 코인이 사용되었는지 직접 드러내지 않으면서 코인의 이중 지출이나 비공개 상태 요소의 중복 소비를 막습니다. Nullifier는 리소스를 노출하지 않은 채 그 리소스를 고유하게 식별하는 단방향 hash입니다.

export ledger usedNullifiers: Set<Bytes<32>>;

circuit nullifier(secretKey: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "nullifier-domain"),
secretKey
]);
}

export circuit spend(secretKey: Bytes<32>): [] {
const nul = nullifier(secretKey);
assert(!usedNullifiers.member(nul), "Already spent");
usedNullifiers.insert(disclose(nul));
}

서로 다른 용도에는 서로 다른 도메인 구분자(예: "nullifier-my-dapp-1" vs "commitment-my-dapp-2")를 사용해서 hash 충돌 공격을 막으세요.

Best practice: Use appropriate cryptographic primitives

용도에 맞는 프리미티브를 선택하세요.

Primitive용도PersistenceDisclosure protection
transientHash임시 검사보장 없음없음
transientCommit임시 hiding보장 없음있음
persistentHash상태 파생, 인증보장됨없음
persistentCommit장기 hiding보장됨있음

Best practice: Use domain separation

용도가 다를 때마다 별도의 도메인 구분자를 두어서 hash 충돌 공격을 막으세요.

circuit publicKey(_sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "commitment-domain"),
_sk
]);
}

circuit nullifier(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "nullifier-domain"), // Different domain
sk
]);
}

Input validation and access control

Compact 컨트랙트는 모든 입력을 검증해야 하며, 유스케이스에 따라 필요할 경우 circuit 실행에 대한 권한 검증을 적용해야 합니다. 이 부분은 Compact 개발자가 짊어져야 할 매우 중요한 책임입니다. 특정 circuit의 접근 제어 수준은 개발자가 설계한 만큼만 보장됩니다.

언어 자체는 이런 보안 기능을 구현하는 데 필요한 내장 메커니즘을 제공합니다.

Assert statements

assert를 사용해서 모든 입력, 상태 전이, 접근 권한 요건을 검증하세요.

export circuit updateBalance(recipient: Bytes<32>, amount: Uint<64>): [] {
// Validate state
assert(state == State.ACTIVE, "Contract not active");

// Validate inputs
assert(amount > 0, "Amount must be greater than zero");
assert(amount <= balance, "Insufficient balance");

// Validate authorization
const _sk = secretKey();
const pk = publicKey(_sk);
assert(pk == owner, "Unauthorized");

// Update balance
balance = balance - amount;
}

Assertion이 제공하는 효과는 다음과 같습니다.

  • Pre-condition checks - 연산 수행 전에 컨트랙트 상태를 확인합니다
  • Input validation - 잘못된 파라미터를 초기에 거부합니다
  • Authorization - 암호학적 검증을 통해 접근 제어를 강제합니다
  • Invariant preservation - 컨트랙트의 일관성을 유지합니다

Best practice: Validate all inputs

Witness 데이터나 circuit 파라미터는 검증 없이 신뢰하면 안 됩니다.

export circuit updateBalance(amount: Uint<64>): [] {
// Validate bounds
assert(amount > 0, "Amount must be greater than zero");
assert(amount <= MAX_TRANSFER, "Amount exceeds limit");

// Validate state
assert(balance >= amount, "Insufficient balance");

// Validate authorization
const _sk = secretKey();
assert(isAuthorized(_sk), "Unauthorized");

balance = balance - amount;
}

Best practice: Handle errors securely

에러 메시지에 민감한 정보가 새어 나가서는 안 됩니다.

// ✅ Good: Generic error message
export circuit withdraw(amount: Uint<64>): [] {
const authorized = checkAuth();
assert(authorized, "Operation not permitted");
}

// ❌ Bad: Leaks private state
export circuit withdraw(amount: Uint<64>): [] {
const sk = secretKey();
const balance = getBalance();
assert(balance >= amount, "Balance " ++ balance ++ " insufficient");
}

Authentication patterns

Compact circuit는 hash 기반 인증을 통해 디지털 서명을 모사할 수 있습니다. 이 패턴을 활용하면 특정 DApp 안에서만 추적 가능한 "DApp 전용 공개키"를 만들 수 있어서 일부 유스케이스에는 도움이 되지만, 다른 유스케이스에는 제약이 생길 수도 있습니다. 이 패턴을 사용하면 publicKey를 한 번만 호출해 두고, 그 컨트랙트 수명 동안 다른 circuit에 접근할 수 있는 권한 주체를 확립할 수 있습니다.

circuit publicKey(_sk: Bytes<32>): Bytes<32> {
const hash = persistentHash<Vector<2, Bytes<32>>>([
pad(32, "midnight:auth:pk"),
_sk
]);
return hash;
}

export circuit setAuthorized(): [] {
const _sk = secretKey();
const pk = publicKey(_sk);

authority = disclose(pk);
}

// only authority can call authorizedOperation
export circuit authorizedOperation(newLedgerValue: Bytes<32>): [] {
const _sk = secretKey();
const pk = publicKey(_sk);
assert(disclose(pk) == authority, "Authorization failed");

// Perform authorized operation
ledgerValue = disclose(newLedgerValue);
}

트랜잭션의 연결 가능성을 끊으려면 publicKey의 해싱 함수에 라운드 카운터를 포함시켜서, 해당 공개키가 의도한 호출 체인에서만 유효하도록 만드세요. 이 방식은 체인의 시작점에서 publicKey를 호출하고, 끝에서 카운터를 증가시켜 이전 공개키를 무효화하는 식으로 동작합니다. 이 패턴의 예시는 Writing a contract를 참고하세요.

ownPublicKey

특정 Compact circuit의 호출자를 검증하는 용도로 ownPublicKey()를 사용하지 마세요. ownPublicKey()는 기술적으로 witness 함수이며, 사용자 프론트엔드 각각이 ownPublicKey() 호출에 악의적인 값을 반환할 수 있습니다. 호출자가 이미 검증된 뒤에만 이 함수를 사용하세요.

Testing and validation

배포 전에 보안 취약점을 발견하려면 충분한 테스트가 필수입니다. 종합적인 테스트 전략, 디버깅 기법, 테스트 예시는 Testing and debugging guide를 참고하세요.

Next steps