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

Using Compact contracts from JavaScript

Compact 컨트랙트를 컴파일하면 영지식 circuit이 생성되고, 그와 함께 동일한 컨트랙트 로직을 실행하는 JavaScript 모듈도 생성됩니다. JavaScript에서 컨트랙트로 하는 모든 작업은 이 모듈을 거칩니다. 컨트랙트의 private 데이터를 공급하는 witness를 연결하고, 네이티브 값으로 circuit을 호출하며, 타입이 지정된 getter로 ledger state를 디코딩합니다. circuit 호출은 circuit이 온체인에서 강제하는 로직을 그대로 실행하므로, 이 모듈로 노드·indexer·proof server 없이도 컨트랙트 동작을 테스트할 수 있습니다. assert 문이 거부해야 하는 경로까지 포함해서 말입니다.

모듈을 오프체인에서 실행하면 컨트랙트 로직은 다루지만 제출 경로의 나머지는 다루지 않습니다. 증명을 생성하거나 검증하지 않고, 트랜잭션을 조립하거나 가격을 책정하지 않으며, 로컬 실행 이후 변경된 ledger state를 반영하지도 않습니다. 따라서 여기서 통과한 호출이라도 트랜잭션이 네트워크에 도달하면 실패할 수 있습니다.

이 가이드는 그 경로를 따라갑니다. 컨트랙트가 필요로 하는 witness를 구현하고, JavaScript에서 circuit을 호출하며, 그 주위에 단위 테스트 스위트를 구성합니다. 마지막의 레퍼런스 섹션은 모듈이 무엇을 export하고 그 에러 메시지가 무엇을 의미하는지 정리합니다. 스택 트레이스나 타입 에러가 여러분을 그 안으로 이끌 때를 위한 것입니다. 예제 전반에는 bulletin board 컨트랙트를 사용합니다.

Prerequisites

이 가이드의 모든 절차에 공통으로 적용됩니다:

  • Compact CLI 설치, 그리고 compact compile이 동작하는 상태.
  • 컴파일된 컨트랙트. 이 가이드는 example-bboardbboard.compact를 컴파일합니다. 어떤 컨트랙트든 동작하며, bulletin board의 이름을 여러분 것으로 바꾸면 됩니다.
  • Vitest@midnight-ntwrk/compact-runtime이 설치된 Node.js.
  • 컴파일러와 일치하는 runtime 버전. 생성된 코드는 import 시점에 이 짝을 강제합니다. 둘 중 하나가 바뀌면 support matrix를 확인하세요.

What the compiler generates

컨트랙트를 컴파일하면 서로를 반영하는 두 개의 아티팩트가 생성됩니다. 네트워크가 검증하는 ZK circuit, 그리고 동일한 컨트랙트 로직을 오프체인에서 실행하는 JavaScript 모듈입니다. 이 둘이 같은 소스에서 함께 생성된다는 점을 이해하는 것이 모듈을 테스트 표면으로 신뢰할 수 있게 하는 근거입니다.

compact compile을 실행하면 컴파일러는 다음을 수행합니다:

  1. .compact 파일을 파싱하고, 증명이 필요한 각 exported circuit, 즉 impure circuit마다 ZK circuit을 방출합니다. bulletin board의 publicKey 같은 exported pure circuit은 JavaScript로만 컴파일됩니다.
  2. 컨트랙트 구조를 반영하는 JavaScript 구현을 생성합니다. 각 circuit의 시그니처를 식별하고, 컨트랙트가 사용하는 모든 Compact 타입에 대한 타입 서술자를 내장하며, 각 circuit을 감싸 네이티브 JavaScript 값으로 호출할 수 있게 합니다.
  3. 생성된 코드를 @midnight-ntwrk/compact-runtime에 링크합니다. 이 공유 라이브러리는 field 연산, 직렬화, 에러 타입, ledger 질의 기계를 구현합니다. 생성된 파일과 runtime이 함께 완전한 실행 환경을 이룹니다.
  4. TypeScript 선언 파일을 방출해 TypeScript 프로젝트에서 모듈에 완전한 타입이 붙게 합니다.

JavaScript 출력물은 컴파일 대상의 contract/ 하위 디렉터리(예: src/managed/bboard/contract/)에, 컴파일러가 함께 방출하는 keys/, zkir/, compiler/ 디렉터리와 나란히 놓입니다:

  • index.js: JavaScript 구현
  • index.d.ts: TypeScript 타입 정의
  • index.js.map: 디버깅용 source map
Generated code only

index.js는 컴파일할 때마다 다시 생성됩니다. circuit을 추가·제거하거나 타입을 바꾸면 재컴파일하세요. 생성된 파일을 절대 손으로 편집하지 마세요.

Implementing witnesses for a contract

생성된 모듈을 로드하고 컨트랙트에 witness를 제공합니다. witness는 circuit이 요청할 때 비밀 키 같은 private 데이터를 공급하는 함수입니다. witness 없이는 컨트랙트를 인스턴스화할 수 없으며, 생성된 생성자는 불완전한 witnesses 객체를 정확한 에러로 거부합니다. 아래 검증이 의존하는 동작이 바로 이것입니다.

Procedure

  1. 컨트랙트를 컴파일하면서 compact compile에 소스와 대상 디렉터리를 넘깁니다. 아래 경로는 컨트랙트가 src/bboard.compact에 있다고 가정합니다. example-bboard에서는 contract/src/bboard.compact에 있으므로 여러분의 레이아웃에 맞게 조정하세요:

    compact compile src/bboard.compact src/managed/bboard
  2. 여느 ES 모듈과 마찬가지로, 컴파일러가 방금 작성한 managed 디렉터리 옆의 파일에서 모듈을 import합니다. TypeScript에서는 선언 파일이 모든 것을 자동으로 타이핑합니다:

    import { Contract, State, ledger, pureCircuits } from './managed/bboard/contract/index.js';
  3. witness가 읽는 private state를 정의하고, Compact 소스가 선언한 witness마다 함수를 하나씩 구현합니다. bulletin board의 경우 localSecretKey입니다:

    import { Ledger } from './managed/bboard/contract/index.js';
    import { WitnessContext } from '@midnight-ntwrk/compact-runtime';

    export type BBoardPrivateState = {
    readonly secretKey: Uint8Array;
    };

    export const createBBoardPrivateState = (secretKey: Uint8Array) => ({
    secretKey,
    });

    export const witnesses = {
    localSecretKey: ({
    privateState,
    }: WitnessContext<Ledger, BBoardPrivateState>): [BBoardPrivateState, Uint8Array] => [
    privateState,
    privateState.secretKey,
    ],
    };

    각 witness는 ledger view, private state, 컨트랙트 주소를 담은 WitnessContext를 받고, 갱신된 private state와 witness 값의 튜플을 반환합니다.

  4. witnesses 객체로 컨트랙트를 인스턴스화합니다:

    const contract = new Contract(witnesses);

Verification

완전한 witnesses 객체는 동작하는 인스턴스를 만들고, 생성된 검증 로직은 불완전한 객체를 거부합니다.

import-witnesses.test.ts
import { describe, it, expect } from 'vitest';
import * as RT from '@midnight-ntwrk/compact-runtime';
import { Contract } from './managed/bboard/contract/index.js';

const COIN = '0'.repeat(64);

const witnesses = {
localSecretKey: ({ privateState }) => [privateState, privateState.secretKey],
};

describe('importing the implementation', () => {
it('wires the witnesses into a working contract instance', () => {
const contract = new Contract(witnesses);
const secretKey = new Uint8Array(32);
const ctor = contract.initialState(RT.createConstructorContext({ secretKey }, COIN));
expect(ctor.currentContractState).toBeDefined();
});

it('rejects a witnesses object missing a declared witness', () => {
expect(() => new Contract({})).toThrow(
'does not contain a function-valued field named localSecretKey',
);
});
});
✓ import-witnesses.test.ts > importing the implementation > wires the witnesses into a working contract instance
✓ import-witnesses.test.ts > importing the implementation > rejects a witnesses object missing a declared witness

Test Files 1 passed (1)
Tests 2 passed (2)

Calling circuits from JavaScript

circuit context를 만들고 인스턴스를 통해 circuit을 호출해 컨트랙트 로직을 오프체인에서 실행합니다. context는 손으로 만들지 말고 runtime 헬퍼로 만드세요. 실제 CircuitContext는 래퍼가 확인하는 query-context state를 담고 있으므로, 손으로 만든 객체는 검증에 실패합니다.

Prerequisites

Procedure

  1. initialState로 genesis state를 만든 뒤, 그로부터 circuit context를 구성합니다. 생성자 context는 초기 private state와 coin public key를 받고, circuit context는 여기에 컨트랙트 주소를 더합니다:

    import * as RT from '@midnight-ntwrk/compact-runtime';

    const COIN = '0'.repeat(64);
    const ADDR = RT.sampleContractAddress();
    const secretKey = new Uint8Array(32);

    const ctor = contract.initialState(RT.createConstructorContext({ secretKey }, COIN));
    const ctx = RT.createCircuitContext(ADDR, COIN, ctor.currentContractState, { secretKey });
  2. context를 넘겨 impure circuit을 호출합니다. 래퍼는 입력을 검증하고 컨트랙트 로직을 실행한 뒤, 결과를 갱신된 context, proof data, gas 비용과 함께 반환합니다:

    const call = contract.impureCircuits.post(ctx, 'Hello from Compact!');

    // call.result -> the circuit's return value ([] for post)
    // call.context -> the updated circuit context
    // call.proofData -> input, output, and transcripts for proof generation
    // call.gasCost -> cost tracking for the call
  3. ledger() 헬퍼로 결과 ledger state를 읽습니다:

    const board = ledger(call.context.currentQueryContext.state);
    // board.state, board.message, board.sequence, board.owner
  4. pure circuit은 context 없이 직접 호출합니다:

    const commitment = pureCircuits.publicKey(secretKey, new Uint8Array(32));

Verification

impure circuit은 board를 occupied 상태로 전환하고 proof data를 반환하며, pure circuit은 context 없이 결정론적으로 계산합니다.

circuits.test.ts
import { describe, it, expect } from 'vitest';
import * as RT from '@midnight-ntwrk/compact-runtime';
import { Contract, State, ledger, pureCircuits } from './managed/bboard/contract/index.js';

const COIN = '0'.repeat(64);
const ADDR = RT.sampleContractAddress();
const key = (n) => { const a = new Uint8Array(32); a[31] = n; return a; };

const witnesses = {
localSecretKey: ({ privateState }) => [privateState, privateState.secretKey],
};

describe('calling contract circuits', () => {
it('runs an impure circuit and returns the result, context, and proof data', () => {
const contract = new Contract(witnesses);
const ctor = contract.initialState(RT.createConstructorContext({ secretKey: key(7) }, COIN));
const ctx = RT.createCircuitContext(ADDR, COIN, ctor.currentContractState, { secretKey: key(7) });

const call = contract.impureCircuits.post(ctx, 'Hello from Compact!');

expect(call.result).toEqual([]);
expect(call.proofData.publicTranscript.length).toBeGreaterThan(0);
expect(call.gasCost).toBeDefined();
const board = ledger(call.context.currentQueryContext.state);
expect(board.state).toBe(State.OCCUPIED);
expect(board.message.value).toBe('Hello from Compact!');
});

it('calls a pure circuit directly, with no circuit context', () => {
const commitment = pureCircuits.publicKey(key(7), key(1));
expect(commitment).toBeInstanceOf(Uint8Array);
expect(commitment.length).toBe(32);
expect(commitment).toEqual(pureCircuits.publicKey(key(7), key(1)));
});
});
✓ circuits.test.ts > calling contract circuits > runs an impure circuit and returns the result, context, and proof data
✓ circuits.test.ts > calling contract circuits > calls a pure circuit directly, with no circuit context

Test Files 1 passed (1)
Tests 2 passed (2)

Writing a unit test suite

일반적인 테스트 프레임워크로 컨트랙트 로직을 테스트합니다. 노드·indexer·proof server가 필요 없습니다. 좋은 스위트는 양방향을 모두 검증합니다. 성공해야 하는 경로와, assert 문이 거부해야 하는 경로 모두를 다루며, 여기에는 잘못된 private state를 가진 caller도 포함됩니다.

Prerequisites

Procedure

  1. 테스트마다 새 컨트랙트와 context를 만드는 setup 헬퍼를 작성합니다:

    const setup = (secretKey = key(7)) => {
    const contract = new Contract(witnesses);
    const ctor = contract.initialState(RT.createConstructorContext({ secretKey }, COIN));
    const ctx = RT.createCircuitContext(ADDR, COIN, ctor.currentContractState, { secretKey });
    return { contract, ctx };
    };
  2. 성공 경로는 타입이 지정된 ledger view로 단언하고, 실패 경로는 Compact 소스의 정확한 assert 메시지에 대해 단언합니다. 공격자를 흉내 내려면 currentPrivateState가 다른 비밀 키를 담은 context로 circuit을 실행하세요:

    const stranger = { ...occupied, currentPrivateState: { secretKey: key(9) } };
    expect(() => contract.impureCircuits.takeDown(stranger)).toThrow(
    'Attempted to take down post, but not the current owner',
    );

Verification

전체 스위트는 genesis state, post와 take-down 수명 주기, 두 거부 경로, 그리고 pure circuit의 결정성을 모두 다룹니다.

bboard.test.ts
import { describe, it, expect } from 'vitest';
import * as RT from '@midnight-ntwrk/compact-runtime';
import { Contract, State, ledger, pureCircuits } from './managed/bboard/contract/index.js';

const COIN = '0'.repeat(64);
const ADDR = RT.sampleContractAddress();
const key = (n) => { const a = new Uint8Array(32); a[31] = n; return a; };

const witnesses = {
localSecretKey: ({ privateState }) => [privateState, privateState.secretKey],
};

const setup = (secretKey = key(7)) => {
const contract = new Contract(witnesses);
const ctor = contract.initialState(RT.createConstructorContext({ secretKey }, COIN));
const ctx = RT.createCircuitContext(ADDR, COIN, ctor.currentContractState, { secretKey });
return { contract, ctx };
};

describe('bulletin board contract', () => {
it('starts vacant', () => {
const { ctx } = setup();
const board = ledger(ctx.currentQueryContext.state);
expect(board.state).toBe(State.VACANT);
expect(board.message.is_some).toBe(false);
expect(board.sequence).toBe(1n);
});

it('accepts a post on a vacant board', () => {
const { contract, ctx } = setup();
const result = contract.impureCircuits.post(ctx, 'Test message');
const board = ledger(result.context.currentQueryContext.state);
expect(board.state).toBe(State.OCCUPIED);
expect(board.message.is_some).toBe(true);
expect(board.message.value).toBe('Test message');
});

it('rejects a post on an occupied board', () => {
const { contract, ctx } = setup();
const occupied = contract.impureCircuits.post(ctx, 'First message').context;
expect(() => contract.impureCircuits.post(occupied, 'Second message')).toThrow(
'Attempted to post to an occupied board',
);
});

it('lets the owner take the post down and returns the message', () => {
const { contract, ctx } = setup();
const occupied = contract.impureCircuits.post(ctx, 'Mine to remove').context;
const takeDown = contract.impureCircuits.takeDown(occupied);
expect(takeDown.result).toBe('Mine to remove');
expect(ledger(takeDown.context.currentQueryContext.state).state).toBe(State.VACANT);
});

it('rejects a take-down from a non-owner', () => {
const { contract, ctx } = setup();
const occupied = contract.impureCircuits.post(ctx, 'Not yours').context;
const stranger = { ...occupied, currentPrivateState: { secretKey: key(9) } };
expect(() => contract.impureCircuits.takeDown(stranger)).toThrow(
'Attempted to take down post, but not the current owner',
);
});

it('computes a deterministic result from the owner-commitment circuit', () => {
const first = pureCircuits.publicKey(key(7), key(1));
const second = pureCircuits.publicKey(key(7), key(1));
const other = pureCircuits.publicKey(key(8), key(1));
expect(first).toBeInstanceOf(Uint8Array);
expect(first.length).toBe(32);
expect(first).toEqual(second);
expect(first).not.toEqual(other);
});
});
✓ bboard.test.ts > bulletin board contract > starts vacant
✓ bboard.test.ts > bulletin board contract > accepts a post on a vacant board
✓ bboard.test.ts > bulletin board contract > rejects a post on an occupied board
✓ bboard.test.ts > bulletin board contract > lets the owner take the post down and returns the message
✓ bboard.test.ts > bulletin board contract > rejects a take-down from a non-owner
✓ bboard.test.ts > bulletin board contract > computes a deterministic result from the owner-commitment circuit

Test Files 1 passed (1)
Tests 6 passed (6)

The generated export surface

모듈과 그 선언 파일이 무엇을 export하고 각 export가 무엇을 위한 것인지 정리합니다. 구현을 애플리케이션이나 테스트 스위트에 연결할 때 참고하세요.

ExportKindPurpose
Contractclasswitness로 인스턴스화하며, circuits, impureCircuits, provableCircuits, initialState()를 노출합니다
pureCircuitsobjectcircuit context 없이 호출 가능한 pure circuit
ledger(state)functionStateValue 또는 ChargedState를 필드별 타입 getter로 디코딩합니다
Stateenum컨트랙트가 export한 Compact enum을 JavaScript로 반영한 것
contractReferenceLocationsconstantledger state 내 컨트랙트 참조에 대한 내부 메타데이터

선언 파일은 TypeScript 프로젝트를 위해 동일한 표면에 타입을 붙입니다:

export type Witnesses<PS> = {
localSecretKey(context: __compactRuntime.WitnessContext<Ledger, PS>): [PS, Uint8Array];
}

// ... State enum and Circuits / ProvableCircuits types omitted ...

export type ImpureCircuits<PS> = {
post(context: __compactRuntime.CircuitContext<PS>, newMessage_0: string): __compactRuntime.CircuitResults<PS, []>;
takeDown(context: __compactRuntime.CircuitContext<PS>): __compactRuntime.CircuitResults<PS, string>;
}

export type PureCircuits = {
publicKey(sk_0: Uint8Array, sequence_0: Uint8Array): Uint8Array;
}

export type Ledger = {
readonly state: State;
readonly message: { is_some: boolean, value: string };
readonly sequence: bigint;
readonly owner: Uint8Array;
}

export declare class Contract<PS = any, W extends Witnesses<PS> = Witnesses<PS>> {
witnesses: W;
circuits: Circuits<PS>;
impureCircuits: ImpureCircuits<PS>;
provableCircuits: ProvableCircuits<PS>;
constructor(witnesses: W);
initialState(context: __compactRuntime.ConstructorContext<PS>): __compactRuntime.ConstructorResult<PS>;
}

// ... ContractReferenceLocations omitted ...

export declare function ledger(state: __compactRuntime.StateValue | __compactRuntime.ChargedState): Ledger;
export declare const pureCircuits: PureCircuits;

제네릭 파라미터 PS는 여러분의 private state 타입으로, witness가 읽고 갱신합니다. 이 선언들 덕분에 TypeScript 프로젝트는 모든 circuit 호출에서 자동 완성과 컴파일 타임 검사를 얻습니다.

모듈의 나머지, 즉 _descriptor_* 객체와 Maybe 같은 복합 타입을 위해 생성된 클래스는 내부 인코딩 기계입니다. 컴파일할 때마다 다시 생성되고 컨트랙트가 바뀌면 번호가 이동하므로, 위의 export만이 지원되는 유일한 표면입니다.

Errors from the generated module

생성된 코드는 세 지점에서 검증합니다. import 시점, Contract 생성자, 그리고 모든 circuit 호출입니다. import 시점 검사는 index.js 맨 위의 버전 가드입니다:

import * as __compactRuntime from '@midnight-ntwrk/compact-runtime';
__compactRuntime.checkRuntimeVersion('0.16.0');

생성된 소스를 읽기 전에, 먼저 이 표에서 에러를 대조하세요:

ErrorCauseFix
runtime 버전을 언급하며 import 시점에 throw설치된 @midnight-ntwrk/compact-runtime이 컴파일러가 기대하는 버전과 호환되지 않음. 0.x runtime의 경우, 호환이란 동일한 minor 버전이면서 기대 patch 이상임을 뜻합니다support matrix로 컴파일러와 runtime을 짝지으세요
Contract constructor: expected 1 argument, received 0생성자는 정확히 인자 하나, 즉 witnesses 객체를 받습니다witnesses 객체만 넘기고 다른 것은 넘기지 마세요
does not contain a function-valued field named localSecretKeywitnesses 객체에 Compact 소스가 선언한 witness가 빠져 있음Implementing witnesses for a contract처럼 선언된 witness마다 함수를 하나씩 구현하세요
post: expected 2 arguments (as invoked from Typescript), received 0impure circuit은 circuit context와 Compact 시그니처의 각 파라미터를 받습니다context를 먼저 넘긴 뒤 circuit 자신의 인자를 넘기세요
.compact 소스의 한 줄을 인용하는 type error: ... expected value of type CircuitContextcontext를 손으로 만들어 래퍼의 currentQueryContext 검사가 실패함Calling circuits from JavaScript처럼 createConstructorContextcreateCircuitContext로 context를 만드세요
expected instance of ChargedStateledger()가 받아들이지 않는 ContractState를 받음circuit 호출에서 나온 state, 또는 배포된 컨트랙트 state의 data 필드를 넘기세요
여러분 Compact 소스의 assert 메시지컨트랙트 로직이 호출을 거부함하네스에서 고칠 것은 없습니다. 이는 테스트가 expect(...).toThrow(...)로 단언하는 거부 경로입니다

Additional resources

  • Bulletin board DApp: 이 컨트랙트를 중심으로 구성한 완전한 애플리케이션.
  • Test and debug: 디버깅 전략과 흔한 실패 시나리오. 코드 예제가 현재 CircuitContext API보다 이전이므로, 이 페이지의 context 패턴을 사용하세요.
  • Security and best practices: 이 하네스 위에 구축하는 적대적 테스트 패턴.
  • Compact runtime API reference: 여기서 쓰인 CircuitContext, WitnessContext, 헬퍼 함수.
  • Support matrix: 어떤 컴파일러 버전이 어떤 runtime 버전과 짝을 이루는지.