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: proof 생성이 허용 가능한 시간 안에 완료되는지 확인합니다.
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 서버, 그 외 시스템 구성 요소와 컨트랙트가 올바르게 상호작용하는지 확인합니다.
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 데이터가 공개 트랜잭션 출력이나 블록체인 상태에 노출되지 않는지 확인합니다. 영지식 proof가 민감한 정보를 제대로 보호하는지 검증하는 핵심 단계입니다.
Test disclosure behavior
명시적으로 공개된 값만 공개 상태에 노출되는지 검증합니다. 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
proof 생성이 실패하거나 시간 초과가 발생할 때는 다음 항목을 살펴봅니다.
- Circuit complexity: circuit 연산 한도가 합리적인지 확인합니다. 지나치게 복잡한 circuit는 proof 서버 용량 한계를 넘을 수 있습니다.
- 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 스마트 컨트랙트 테스트 전략을 살펴봤다면 다음 문서를 이어서 확인해 보세요.
- 보안 모범 사례는 smart contract security 가이드를 참고합니다.
- JavaScript 구현으로 컨트랙트를 테스트하는 방법은 Compact JavaScript implementation 가이드를 참고합니다.