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

Test and debug

Compact 스마트 컨트랙트를 테스트할 때는 기능적 정합성과 보안 속성을 모두 검증해야 합니다.

Why test smart contracts?​

Compact 스마트 컨트랙트는 여러 컨텍스트(온체인 ledger, 영지식 circuit, 로컬 witness)에서 실행되므로 각 레이어마다 다른 테스트 전략이 필요합니다. 적절한 테스트를 통해 컨트랙트가 올바르게 동작하고, 프라이버시 보장이 유지되며, 엣지 케이스가 제대로 처리되는지 확인할 수 있습니다.

이 문서에서는 다음과 같은 테스트 전략을 다룹니다.

  • Circuit logic testing: 개별 circuit이 올바른 출력과 상태 전이를 만들어내는지 검증합니다.
  • Privacy verification: 비공개 데이터가 공개 출력으로 새어나가지 않는지 확인합니다.
  • Authorization testing: 접근 제어 메커니즘이 권한 없는 작업을 막는지 확인합니다.
  • Integration testing: 테스트 네트워크에서 전체 트랜잭션 흐름을 테스트합니다.
  • Performance testing: 증명 생성이 허용 가능한 시간 안에 완료되는지 확인합니다.

Input validation patterns​

circuit 경계에서 모든 입력을 검증해 잘못된 상태 전이와 보안 취약점을 막습니다. 입력을 제대로 검증해야 컨트랙트가 올바르게 동작하고 악의적이거나 형식이 어긋난 입력을 거부할 수 있습니다.

Comprehensive validation example​

다음은 transfer circuit에 여러 검증 기법을 적용한 예시입니다.

const MAX_AMOUNT: Uint<64> = 1000000;
const MIN_AMOUNT: Uint<64> = 1;

ledger balance: Uint<64>;

export circuit transfer(recipient: Bytes<32>, amount: Uint<64>): [] {
// Bounds checking
assert(amount >= MIN_AMOUNT, "Amount too small");
assert(amount <= MAX_AMOUNT, "Amount exceeds maximum");

// State validation
assert(balance >= amount, "Insufficient funds");

// Format validation
assert(recipient != Bytes<32>{}, "Invalid recipient");

// Execute transfer
balance = balance - amount;
}

Unit test circuits​

유닛 테스트는 개별 circuit 동작을 격리된 상태에서 검증합니다. 컨트랙트가 모든 시나리오를 제대로 처리하는지 확인하려면 성공 경로와 실패 조건을 모두 테스트해야 합니다.

Test circuit execution​

다음 예시는 Counter contract의 기본 increment 동작을 테스트합니다. 전체 블록체인 배포 없이 circuit 단위에서 유닛 테스트를 수행하는 방법입니다.

import { describe, it, expect } from '@jest/globals';
import { Contract } from '../managed/counter/contract/index.js';
import { witnesses, type CounterPrivateState } from '../witnesses.js';

describe('Counter circuit', () => {
it('should increment counter value', async () => {
const contract = new Contract(witnesses);
const privateState: CounterPrivateState = { privateCounter: 0 };

// Create circuit context
const context = {
privateState,
ledgerState: { round: 0n }
};

// Call increment circuit
const result = contract.impureCircuits.increment(context);

// Verify ledger state updated
expect(result.newLedgerState.round).toBe(1n);
});
});

Test state transitions​

컨트랙트가 유효한 상태 머신 전이를 강제하고 잘못된 전이를 거부하는지 테스트합니다. bulletin board 컨트랙트는 두 상태 간 검증 예시를 보여줍니다.

import { Contract } from '../managed/bboard/contract/index.js';
import { witnesses, type BBoardPrivateState } from '../witnesses.js';
import { State } from '../managed/bboard/contract/index.js';

describe('Bulletin board state transitions', () => {
it('should enforce valid state transitions', async () => {
const contract = new Contract(witnesses);
const privateState: BBoardPrivateState = {
secretKey: new Uint8Array(32)
};

// Initial state - board is vacant
const context = {
privateState,
ledgerState: {
state: State.VACANT,
message: { is_some: false, value: '' },
sequence: 0n,
owner: new Uint8Array(32)
}
};

// Valid transition: post to vacant board
const postResult = contract.impureCircuits.post(context, 'Hello');
expect(postResult.newLedgerState.state).toBe(State.OCCUPIED);
expect(postResult.newLedgerState.message.value).toBe('Hello');

// Invalid transition: post to occupied board should fail
expect(() => {
contract.impureCircuits.post(postResult.newContext, 'World');
}).toThrow('Board is occupied');
});
});

Test boundary conditions​

엣지 케이스와 경계값을 테스트해 컨트랙트가 한계 조건을 제대로 처리하는지 확인합니다.

import { Contract } from '../managed/counter/contract/index.js';
import { witnesses, type CounterPrivateState } from '../witnesses.js';

describe('Boundary conditions', () => {
it('should handle Counter operations correctly', async () => {
const contract = new Contract(witnesses);
const privateState: CounterPrivateState = { privateCounter: 0 };

// Test normal increment
const context = {
privateState,
ledgerState: { round: 0n }
};

const result = contract.impureCircuits.increment(context);
expect(result.newLedgerState.round).toBe(1n);

// Test multiple increments
let currentContext = result.newContext;
for (let i = 0; i < 10; i++) {
const nextResult = contract.impureCircuits.increment(currentContext);
currentContext = nextResult.newContext;
}
expect(currentContext.ledgerState.round).toBe(11n);
});

it('should validate input bounds with assertions', async () => {
// Example of testing bounds validation in circuit
const MAX_AMOUNT = 1000000n;
const balance = 500000n;

// This would be tested in your circuit's assert statements
expect(() => {
if (MAX_AMOUNT + 1n > balance) {
throw new Error('Amount exceeds maximum');
}
}).toThrow('Amount exceeds maximum');
});
});

Integration test​

통합 테스트는 테스트 네트워크(Preview 또는 Preprod)에서 전체 트랜잭션 흐름을 검증합니다. Midnight 블록체인, proof server, 그 외 시스템 구성 요소와 컨트랙트가 올바르게 상호작용하는지 확인합니다.

Transaction finalization test​

트랜잭션이 정상적으로 완료되어 온체인 상태를 갱신하는지 테스트합니다. 제출부터 확정까지 트랜잭션 전체 라이프사이클을 검증합니다.

it('should handle transaction finalization correctly', async () => {
// Submit transaction
const tx = await deployedContract.callTx.increment();

// Wait for transaction confirmation
const receipt = await tx.wait();

expect(receipt.status).toBe('APPLIED_TO_CHAIN');
expect(receipt.found).toBe(true);

// Verify state updated on-chain
const contractState = await providers.publicDataProvider.contractStateObservable(
deployedContract.deployTxData.public.contractAddress,
{ type: 'latest' }
).toPromise();

expect(contractState.data.round).toBeGreaterThan(0n);
});

Privacy verification​

프라이버시 테스트는 비공개 witness 데이터가 공개 트랜잭션 출력이나 블록체인 상태에 노출되지 않는지 확인합니다. 영지식 증명이 민감한 정보를 제대로 보호하는지 검증하는 핵심 단계입니다.

Test disclosure behavior​

명시적으로 공개된 값만 public state에 노출되는지 검증합니다. Compact의 disclose() 함수는 witness 값을 공개 대상으로 표시합니다.

import { Contract } from '../managed/bboard/contract/index.js';
import { witnesses, type BBoardPrivateState } from '../witnesses.js';
import { State } from '../managed/bboard/contract/index.js';

it('should only disclose explicitly disclosed values', async () => {
const contract = new Contract(witnesses);
const secretKey = new Uint8Array(32);
crypto.getRandomValues(secretKey);
const privateState: BBoardPrivateState = { secretKey };

const context = {
privateState,
ledgerState: {
state: State.VACANT,
message: { is_some: false, value: '' },
sequence: 0n,
owner: new Uint8Array(32)
}
};

// Call circuit that uses secret key via witness
const result = contract.impureCircuits.post(context, 'Message');

// Owner field should contain disclosed public key hash, not secret key
expect(result.newLedgerState.owner).toBeDefined();
expect(result.newLedgerState.owner).not.toEqual(secretKey);

// Message should be disclosed (it's a public field)
expect(result.newLedgerState.message.value).toBe('Message');

// Secret key remains in private state, not ledger
expect(result.newContext.privateState.secretKey).toEqual(secretKey);
});

Negative test​

Negative test는 컨트랙트가 잘못된 작업과 권한 없는 접근을 제대로 거부하는지 검증합니다. 컨트랙트의 보안 속성을 보장하기 위해 꼭 필요한 단계입니다.

Test assertion failures​

전제 조건이 충족되지 않은 상황에서 circuit이 작업을 거부하는지 테스트합니다. Compact의 assert 구문은 런타임에 불변 조건을 강제합니다.

import { Contract } from '../managed/bboard/contract/index.js';
import { witnesses, type BBoardPrivateState } from '../witnesses.js';
import { State } from '../managed/bboard/contract/index.js';

describe('Assertion handling', () => {
it('should reject takeDown on vacant board', async () => {
const contract = new Contract(witnesses);
const privateState: BBoardPrivateState = {
secretKey: new Uint8Array(32)
};

const vacantContext = {
privateState,
ledgerState: {
state: State.VACANT,
message: { is_some: false, value: '' },
sequence: 0n,
owner: new Uint8Array(32)
}
};

// Attempt to take down when board is vacant
expect(() => {
contract.impureCircuits.takeDown(vacantContext);
}).toThrow('Board is vacant');
});

it('should enforce authorization for takeDown', async () => {
const ownerKey = new Uint8Array(32);
const attackerKey = new Uint8Array(32);
crypto.getRandomValues(ownerKey);
crypto.getRandomValues(attackerKey);

const vacantState = {
state: State.VACANT,
message: { is_some: false, value: '' },
sequence: 0n,
owner: new Uint8Array(32)
};

// Post with owner key
const ownerPrivateState: BBoardPrivateState = { secretKey: ownerKey };
const postResult = contract.impureCircuits.post(
{ privateState: ownerPrivateState, ledgerState: vacantState },
'Message'
);

// Attempt takeDown with different key
const attackerPrivateState: BBoardPrivateState = { secretKey: attackerKey };
expect(() => {
contract.impureCircuits.takeDown({
privateState: attackerPrivateState,
ledgerState: postResult.newLedgerState
});
}).toThrow('Not authorized');
});
});

Test double-spend prevention​

nullifier가 동일 리소스의 재사용을 막는지 테스트합니다. nullifier는 비공개 데이터에서 파생되는 일회용 식별자로, 이중 지불을 방지합니다.

it('should prevent double-spend with sequence tracking', async () => {
const secretKey = new Uint8Array(32);
crypto.getRandomValues(secretKey);
const privateState: BBoardPrivateState = { secretKey };

const vacantState = {
state: State.VACANT,
message: { is_some: false, value: '' },
sequence: 0n,
owner: new Uint8Array(32)
};

// First post should succeed
const firstPost = contract.impureCircuits.post(
{ privateState, ledgerState: vacantState },
'First message'
);
expect(firstPost.newLedgerState.sequence).toBe(1n);

// Take down and post again should increment sequence
const takeDown = contract.impureCircuits.takeDown({
privateState,
ledgerState: firstPost.newLedgerState
});

const secondPost = contract.impureCircuits.post(
{ privateState, ledgerState: takeDown.newLedgerState },
'Second message'
);

// Sequence increments to prevent replay attacks
expect(secondPost.newLedgerState.sequence).toBe(2n);
});

Debug strategies​

컨트랙트가 의도와 다르게 동작할 때는 체계적인 디버깅으로 근본 원인을 찾아야 합니다. 다음 전략은 circuit 실행을 추적하고, 상태 변화를 들여다보고, 자주 발생하는 문제를 진단하는 데 도움이 됩니다.

Enable verbose logging​

로깅을 활용해 컨트랙트 실행을 추적하고 circuit 동작을 파악합니다.

import pino from 'pino';

const logger = pino({ level: 'debug' });

// Log before transaction
logger.debug('Submitting increment transaction');

const tx = await deployedContract.callTx.increment();

// Log transaction details
logger.debug('Transaction submitted');

const receipt = await tx.wait();
logger.debug({
txId: receipt.public.txId,
blockHeight: receipt.public.blockHeight,
status: receipt.status
}, 'Transaction confirmed');

Inspect circuit execution​

테스트 중 상태 전이를 파악할 수 있도록 상세한 로깅을 추가합니다.

it('should debug circuit state changes', async () => {
// Query initial ledger state
const initialContractState = await providers.publicDataProvider.contractStateObservable(
deployedContract.deployTxData.public.contractAddress,
{ type: 'latest' }
).toPromise();

console.log('Initial ledger state:', initialContractState.data);

// Submit transaction
const tx = await deployedContract.callTx.post('Debug message');
console.log('Transaction submitted');

// Wait and inspect receipt
const receipt = await tx.wait();
console.log('Transaction receipt:', {
status: receipt.status,
blockHeight: receipt.public.blockHeight,
txId: receipt.public.txId
});

// Query final state
const finalContractState = await providers.publicDataProvider.contractStateObservable(
deployedContract.deployTxData.public.contractAddress,
{ type: 'latest' }
).toPromise();

console.log('Final ledger state:', finalContractState.data);

// Verify state transition
expect(finalContractState.data.state).toBe(State.OCCUPIED);
});

Common debug scenarios​

다음은 Compact 컨트랙트 개발에서 자주 마주치는 문제 유형과 진단 방법입니다.

Circuit execution failures​

circuit 실행이 실패할 때는 다음 원인을 점검합니다.

  • Witness function return types: witness 함수가 witness 선언과 맞는 튜플(예: [PrivateState, ReturnValue])을 반환하는지 확인합니다.
  • Assert conditions: 테스트 입력에 대해 모든 assert 구문이 통과하는지 점검합니다. 어떤 assert에서 실패하는지 파악할 수 있도록 비교 대상 값을 로그로 남깁니다.
  • Variable initialization: 모든 변수를 사용 전에 초기화했는지 확인합니다. Compact는 초기화되지 않은 변수의 읽기를 허용하지 않습니다.
  • Bounded integer types: Uint<8>, Uint<64> 같은 타입의 범위를 다시 확인합니다. 값이 선언된 범위 안에 들어오는지 점검합니다.

Proof generation failures​

증명 생성이 실패하거나 시간 초과가 발생할 때는 다음 항목을 살펴봅니다.

  • Circuit complexity: circuit 연산 한도가 합리적인지 확인합니다. 지나치게 복잡한 circuit은 proof server 용량 한계를 넘을 수 있습니다.
  • Infinite loops: 종료되지 않을 가능성이 있는 루프를 확인합니다. 종료를 보장하려면 루프 카운터에 한도를 두세요.
  • Witness data types: witness 함수가 circuit이 기대하는 형식의 데이터를 반환하는지 확인합니다. Uint8Array 값의 크기와 bigint 값의 유효 범위를 점검합니다.
  • Memory constraints: 메모리 한도를 넘길 수 있는 큰 데이터 구조를 점검합니다. 큰 컬렉션이라면 청크 분할이나 페이지네이션을 검토합니다.

State synchronization issues​

ledger 상태가 기대대로 갱신되지 않을 때는 다음 사항을 확인합니다.

  • Transaction finalization: 상태를 조회하기 전에 await tx.wait()로 트랜잭션이 완료됐는지 확인합니다. 너무 일찍 조회하면 오래된 데이터가 돌아올 수 있습니다.
  • Witness consistency: witness 함수가 호출마다 일관된 값을 반환하는지 확인합니다. 호출 사이에 값이 바뀔 수 있는 외부 의존성은 피합니다.
  • Ledger field operations: ledger 필드를 어떻게 수정하는지 다시 살펴봅니다. 필드 타입에 적합한 연산인지(예: Counter.increment()와 직접 대입의 차이) 확인합니다.

Next steps​

Compact 스마트 컨트랙트 테스트 전략을 살펴봤다면 다음 문서를 이어서 확인해 보세요.