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

Using Compact contracts from JavaScript

Compiling a Compact contract produces zero-knowledge circuits and, alongside them, a JavaScript module that runs the same contract logic. Everything you do with a contract from JavaScript goes through that module: you wire in the witnesses that supply its private data, call its circuits with native values, and decode its ledger state through typed getters. Because a circuit call runs the logic the circuit enforces on-chain, the module also makes contract behavior testable, including the paths your assert statements are supposed to reject, without a node, an indexer, or a proof server.

Running the module off-chain covers contract logic, not the rest of the submission path. It does not generate or verify a proof, assemble or price a transaction, or account for ledger state that changed since your local run, so a call that passes here can still fail when the transaction reaches the network.

This guide walks that path: implement the witnesses a contract needs, call its circuits from JavaScript, and build a unit test suite around it. The reference sections at the end list what the module exports and what its error messages mean, for when a stack trace or a type error sends you into it. The examples use the private bid contract from midnight-examples throughout: a bidder commits to an amount that must meet a public minimum, and can open the commitment later. How to build a private smart contract writes that contract line by line; this guide picks it up at the compiled output.

Prerequisites​

These apply to every procedure in this guide:

  • The Compact CLI installed, with compact compile working.
  • A compiled contract. This guide compiles private-bid.compact from the private-bid example; any contract works, with your own names in place of the private bid's.
  • Node.js with Vitest and @midnight-ntwrk/compact-runtime installed.
  • The Compact compiler and @midnight-ntwrk/compact-runtime at the versions the support matrix lists for Mainnet, Preview, and Preprod, with that compiler set as your default. Update the compiler version shows how to pin it. The generated code checks the pairing at import time, so a mismatch fails with Version mismatch.

What the compiler generates​

Compiling a contract produces two artifacts that mirror each other: the ZK circuits the network verifies, and a JavaScript module that executes the identical contract logic off-chain. Understanding that the two are generated together, from the same source, is what makes the module trustworthy as a testing surface.

When you run compact compile, the compiler:

  1. Parses your .compact file and emits a ZK circuit for each exported circuit that needs a proof, that is, the impure circuits. An exported pure circuit compiles to JavaScript only. The private bid exports two impure circuits, placeBid and revealBid, and keeps its derive helper private, so the compiler reports Compiling 2 circuits: and emits no pure-circuit entry point.
  2. Generates a JavaScript implementation that mirrors the contract's structure: it identifies each circuit's signature, embeds type descriptors for every Compact type the contract uses, and wraps each circuit so you can invoke it with native JavaScript values.
  3. Links the generated code against @midnight-ntwrk/compact-runtime, the shared library that implements field arithmetic, serialization, error types, and the ledger query machinery. The generated file and the runtime together form a complete execution environment.
  4. Emits a TypeScript declaration file so the module is fully typed in a TypeScript project.

The JavaScript output lands in the contract/ subdirectory of your compilation target (for example src/managed/private-bid/contract/), alongside the keys/, zkir/, and compiler/ directories the compiler also emits:

  • index.js: the JavaScript implementation
  • index.d.ts: TypeScript type definitions
  • index.js.map: source map for debugging
Generated code only

index.js is regenerated on every compilation. If you add or remove circuits or change types, recompile; never edit the generated files by hand.

Implementing witnesses for a contract​

Load the generated module and give the contract its witnesses: the functions that supply private data, such as a secret key, when a circuit asks for it. The contract cannot be instantiated without them, and the generated constructor rejects an incomplete witnesses object with a precise error, which is the behavior the verification below relies on.

Procedure​

  1. Compile the contract, giving compact compile the source and the target directory. The paths below assume the contract lives at src/private-bid.compact. The copy in midnight-examples sits at contract/private-bid.compact, so adjust to match your layout:

    compact compile src/private-bid.compact src/managed/private-bid
    Compiling 2 circuits:
  2. Import the module like any other ES module, from a file alongside the managed directory the compiler just wrote. In TypeScript, the declaration file types everything automatically:

    import { Contract, ledger, pureCircuits } from './managed/private-bid/contract/index.js';
  3. Define the private state your witnesses read, and implement one function per witness the Compact source declares. For the private bid, that is localSecretKey, which returns the bidder's 32-byte secret key:

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

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

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

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

    Each witness receives a WitnessContext carrying the ledger view, the private state, and the contract address, and returns a tuple of the updated private state and the witness value.

  4. Instantiate the contract with the witnesses object:

    const contract = new Contract(witnesses);

Verification​

A complete witnesses object produces a working instance, and the generated validation rejects an incomplete one. The Compact constructor's own parameter, the minimum bid, follows the constructor context. initialState returns a promise, so the test awaits it.

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

const COIN = '0'.repeat(64);
const MINIMUM_BID = 100n;

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

describe('importing the implementation', () => {
it('wires the witnesses into a working contract instance', async () => {
const contract = new Contract(witnesses);
const secretKey = new Uint8Array(32);
const ctor = await contract.initialState(RT.createConstructorContext({ secretKey }, COIN), MINIMUM_BID);
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)

Vitest also prints a warning that the source map for index.js points to missing source files. The map references the standard library source, which the compiler does not write out, so the warning is harmless.

Calling circuits from JavaScript​

Run contract logic off-chain by building a circuit context and invoking circuits through the instance. Build the context with the runtime helpers rather than by hand. A real CircuitContext carries the call and query-context state that the wrappers read, so a hand-built object fails when you call a circuit with it.

Prerequisites​

Procedure​

  1. Create the genesis state with initialState, then build a circuit context from it. The constructor context takes the initial private state and a coin public key; the Compact constructor's own parameters come after it, so for the private bid initialState also takes the minimum bid. initialState returns a promise. The circuit context takes an options object that adds the name of the circuit you call and the contract address:

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

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

    const ctor = await contract.initialState(RT.createConstructorContext({ secretKey }, COIN), MINIMUM_BID);
    const ctx = RT.createCircuitContext({
    circuitId: 'placeBid',
    contractAddress: ADDR,
    coinPublicKeyOrZswapState: COIN,
    contractState: ctor.currentContractState,
    privateState: { secretKey },
    });
  2. Call an impure circuit with the context and its arguments. A Uint<64> parameter is a bigint on the JavaScript side, so the amount is 250n. The wrapper validates the inputs, runs the contract logic, and resolves to the result together with the updated context and the gas cost. The proof data for each call is in the context's callProofDataTrace:

    const call = await contract.impureCircuits.placeBid(ctx, 250n);

    // call.result -> the circuit's return value ([] for placeBid)
    // call.context -> the updated circuit context
    // call.gasCost -> cost tracking for the call
    // call.context.callProofDataTrace -> input, output, and transcripts for proof generation, one entry per call

    To run another circuit on the updated state, pass call.context to it with callContext.circuitId set to that circuit. The runtime labels each proof data entry with the context's circuitId:

    const next = { ...call.context, callContext: { ...call.context.callContext, circuitId: 'revealBid' } };
    await contract.impureCircuits.revealBid(next, 250n);
  3. Read the resulting ledger state with the ledger() helper. Scalar fields such as minimumBid are plain values; Map fields such as bidCommitments and revealedBids are views with isEmpty(), size(), member(key), lookup(key), and an iterator over [key, value] pairs:

    const auction = ledger(call.context.callContext.currentQueryContext.state);
    // auction.minimumBid, auction.bidCommitments, auction.revealedBids
  4. Call pure circuits directly, with no context at all. A contract that exports a pure circuit gets it on pureCircuits under its Compact name; it takes and returns native values. The private bid exports none: derive is a private helper, so pureCircuits is an empty object here:

    Object.keys(pureCircuits); // [] for the private bid: derive is not exported

Verification​

The impure circuit records a commitment and adds an entry to the proof data trace, and the module exports no pure circuits for this contract.

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

const COIN = '0'.repeat(64);
const ADDR = RT.sampleContractAddress();
const MINIMUM_BID = 100n;
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 and context, with proof data in the trace', async () => {
const contract = new Contract(witnesses);
const ctor = await contract.initialState(RT.createConstructorContext({ secretKey: key(7) }, COIN), MINIMUM_BID);
const ctx = RT.createCircuitContext({
circuitId: 'placeBid',
contractAddress: ADDR,
coinPublicKeyOrZswapState: COIN,
contractState: ctor.currentContractState,
privateState: { secretKey: key(7) },
});

const call = await contract.impureCircuits.placeBid(ctx, 250n);

expect(call.result).toEqual([]);
expect(call.gasCost).toBeDefined();
const [proofData] = call.context.callProofDataTrace;
expect(proofData.circuitId).toBe('placeBid');
expect(proofData.publicTranscript.length).toBeGreaterThan(0);
const auction = ledger(call.context.callContext.currentQueryContext.state);
expect(auction.minimumBid).toBe(MINIMUM_BID);
expect(auction.bidCommitments.size()).toBe(1n);
expect(auction.revealedBids.isEmpty()).toBe(true);
});

it('exports no pure circuits, because derive is not exported', () => {
expect(Object.keys(pureCircuits)).toEqual([]);
});
});
✓ circuits.test.ts > calling contract circuits > runs an impure circuit and returns the result and context, with proof data in the trace
✓ circuits.test.ts > calling contract circuits > exports no pure circuits, because derive is not exported

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

Writing a unit test suite​

Test contract logic with an ordinary test framework, no node, indexer, or proof server required. A good suite exercises both directions: the paths that must succeed, and the paths your assert statements must reject, including a caller with the wrong private state.

Prerequisites​

Procedure​

  1. Write a setup helper that builds a fresh contract and context per test. Taking the contract address as a parameter lets one test check that the bidder key depends on it:

    const setup = async (secretKey = key(7), address = ADDR) => {
    const contract = new Contract(witnesses);
    const ctor = await contract.initialState(RT.createConstructorContext({ secretKey }, COIN), MINIMUM_BID);
    const ctx = RT.createCircuitContext({
    circuitId: 'placeBid',
    contractAddress: address,
    coinPublicKeyOrZswapState: COIN,
    contractState: ctor.currentContractState,
    privateState: { secretKey },
    });
    return { contract, ctx };
    };
  2. Assert the success paths through the typed ledger view, and the failure paths against the exact assert messages from the Compact source. A circuit call returns a promise, so a failed assert rejects it: check it with await expect(...).rejects.toThrow(...). To simulate an attacker, run a circuit with a context whose callContext.currentPrivateState holds a different secret. A reveal from a key that never bid fails the membership check:

    const stranger = { ...placed, callContext: { ...placed.callContext, circuitId: 'revealBid', currentPrivateState: { secretKey: key(9) } } };
    await expect(contract.impureCircuits.revealBid(stranger, BID)).rejects.toThrow(
    'This bidder has not placed a bid',
    );

Verification​

The full suite covers the genesis state, the place and reveal lifecycle, all four rejection paths, and the binding of the bidder key to the contract address.

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

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

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

const setup = async (secretKey = key(7), address = ADDR) => {
const contract = new Contract(witnesses);
const ctor = await contract.initialState(RT.createConstructorContext({ secretKey }, COIN), MINIMUM_BID);
const ctx = RT.createCircuitContext({
circuitId: 'placeBid',
contractAddress: address,
coinPublicKeyOrZswapState: COIN,
contractState: ctor.currentContractState,
privateState: { secretKey },
});
return { contract, ctx };
};

const stateOf = (context) => ledger(context.callContext.currentQueryContext.state);

describe('private bid contract', () => {
it('starts with the minimum and no bids', async () => {
const { ctx } = await setup();
const auction = stateOf(ctx);
expect(auction.minimumBid).toBe(MINIMUM_BID);
expect(auction.bidCommitments.isEmpty()).toBe(true);
expect(auction.revealedBids.isEmpty()).toBe(true);
});

it('rejects a bid below the minimum', async () => {
const { contract, ctx } = await setup();
await expect(contract.impureCircuits.placeBid(ctx, 50n)).rejects.toThrow('Bid is below the minimum');
});

it('accepts a bid and stores a commitment, not the amount', async () => {
const { contract, ctx } = await setup();
const placed = await contract.impureCircuits.placeBid(ctx, BID);
const auction = stateOf(placed.context);
expect(auction.bidCommitments.size()).toBe(1n);
const [[bidder, commitment]] = [...auction.bidCommitments];
expect(bidder).toHaveLength(32);
expect(commitment).toHaveLength(32);
expect(auction.revealedBids.isEmpty()).toBe(true);
});

it('rejects a second bid from the same bidder', async () => {
const { contract, ctx } = await setup();
const placed = (await contract.impureCircuits.placeBid(ctx, BID)).context;
await expect(contract.impureCircuits.placeBid(placed, BID + 1n)).rejects.toThrow(
'This bidder already placed a bid',
);
});

it('rejects a reveal from a different secret key', async () => {
const { contract, ctx } = await setup();
const placed = (await contract.impureCircuits.placeBid(ctx, BID)).context;
const stranger = { ...placed, callContext: { ...placed.callContext, circuitId: 'revealBid', currentPrivateState: { secretKey: key(9) } } };
await expect(contract.impureCircuits.revealBid(stranger, BID)).rejects.toThrow(
'This bidder has not placed a bid',
);
});

it('rejects a reveal with the wrong amount', async () => {
const { contract, ctx } = await setup();
const placed = (await contract.impureCircuits.placeBid(ctx, BID)).context;
await expect(contract.impureCircuits.revealBid({ ...placed, callContext: { ...placed.callContext, circuitId: 'revealBid' } }, 300n)).rejects.toThrow(
'Amount does not match the committed bid',
);
});

it('reveals the committed amount under the same bidder key', async () => {
const { contract, ctx } = await setup();
const placed = (await contract.impureCircuits.placeBid(ctx, BID)).context;
const revealed = await contract.impureCircuits.revealBid({ ...placed, callContext: { ...placed.callContext, circuitId: 'revealBid' } }, BID);
expect(revealed.result).toEqual([]);
const auction = stateOf(revealed.context);
const [[bidder, amount]] = [...auction.revealedBids];
expect(amount).toBe(BID);
expect(auction.bidCommitments.member(bidder)).toBe(true);
});

it('derives a different bidder key for each contract address', async () => {
const first = await setup(key(7), ADDR);
const second = await setup(key(7), RT.sampleContractAddress());
const bidderFor = async ({ contract, ctx }) => {
const placed = await contract.impureCircuits.placeBid(ctx, BID);
const [[bidder]] = [...stateOf(placed.context).bidCommitments];
return bidder;
};
expect(await bidderFor(first)).not.toEqual(await bidderFor(second));
});
});
✓ private-bid.test.ts > private bid contract > starts with the minimum and no bids
✓ private-bid.test.ts > private bid contract > rejects a bid below the minimum
✓ private-bid.test.ts > private bid contract > accepts a bid and stores a commitment, not the amount
✓ private-bid.test.ts > private bid contract > rejects a second bid from the same bidder
✓ private-bid.test.ts > private bid contract > rejects a reveal from a different secret key
✓ private-bid.test.ts > private bid contract > rejects a reveal with the wrong amount
✓ private-bid.test.ts > private bid contract > reveals the committed amount under the same bidder key
✓ private-bid.test.ts > private bid contract > derives a different bidder key for each contract address

Test Files 1 passed (1)
Tests 8 passed (8)

The generated export surface​

What the module and its declaration file export, and what each export is for. Consult this when wiring the implementation into an application or test suite.

ExportKindPurpose
ContractclassInstantiated with your witnesses; exposes circuits, impureCircuits, provableCircuits, and initialState()
pureCircuitsobjectPure circuits callable without a circuit context; an empty object for the private bid, which exports none
ledger(state)functionDecodes a StateValue or ChargedState into typed per-field getters
expectedVk, circuitSignatures, declaredInterfacesconstantsMetadata the runtime reads when another contract calls this one: each circuit's verifier key fingerprint and signature, and the contract types this contract calls through (none for the private bid)

The module also mirrors any exported Compact enum as a JavaScript enum with the same member names; the private bid declares none.

The declaration file types the same surface for TypeScript projects:

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

// ... Circuits and ProvableCircuits types omitted ...

export type ImpureCircuits<PS> = {
placeBid(context: __compactRuntime.CircuitContext<PS>, amount_0: bigint): Promise<__compactRuntime.CircuitResults<PS, []>>;
revealBid(context: __compactRuntime.CircuitContext<PS>, amount_0: bigint): Promise<__compactRuntime.CircuitResults<PS, []>>;
}

export type PureCircuits = {
}

export type Ledger = {
readonly minimumBid: bigint;
bidCommitments: {
isEmpty(): boolean;
size(): bigint;
member(key_0: Uint8Array): boolean;
lookup(key_0: Uint8Array): Uint8Array;
[Symbol.iterator](): Iterator<[Uint8Array, Uint8Array]>
};
revealedBids: {
isEmpty(): boolean;
size(): bigint;
member(key_0: Uint8Array): boolean;
lookup(key_0: Uint8Array): bigint;
[Symbol.iterator](): Iterator<[Uint8Array, bigint]>
};
}

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>,
minimum_0: bigint): Promise<__compactRuntime.ConstructorResult<PS>>;
}

export declare function ledger(state: __compactRuntime.StateValue | __compactRuntime.ChargedState): Ledger;
export declare const pureCircuits: PureCircuits;
export declare const expectedVk: Record<string, string>;
export declare const circuitSignatures: __compactRuntime.CircuitSignatures;
export declare const declaredInterfaces: __compactRuntime.DeclaredInterfaces;

The generic parameter PS is your private state type, which the witnesses read and update. Each Compact parameter appears with a _0 suffix and its JavaScript type, so Uint<64> becomes bigint and Bytes<32> becomes Uint8Array. initialState and every circuit return a promise. With these declarations, a TypeScript project gets autocomplete and compile-time checking on every circuit call.

Everything else in the module, the _descriptor_* objects and the classes generated for composite types such as ContractAddress and Either, is internal encoding machinery. It is regenerated on every compile and its numbering shifts as the contract changes, so the exports above are the only supported surface.

Errors from the generated module​

The generated code validates at three points: at import time, in the Contract constructor, and on every circuit call. The import-time check is the version guard at the top of index.js, which names the runtime version your compiler expects:

import * as __compactRuntime from '@midnight-ntwrk/compact-runtime';
__compactRuntime.checkRuntimeVersion('<expected runtime version>');

Match an error against this table before reading the generated source:

ErrorCauseFix
Version mismatch: compiled code expects <version>, runtime is <version>, thrown at importThe installed @midnight-ntwrk/compact-runtime is not compatible with the version the compiler expects: for a 0.x runtime, compatible means the same minor version and at least the expected patchPair the compiler and runtime using the support matrix
Contract constructor: expected 1 argument, received 0The constructor takes exactly one argument, the witnesses objectPass the witnesses object, and nothing else
does not contain a function-valued field named localSecretKeyThe witnesses object is missing a witness the Compact source declaresImplement one function per declared witness, as in Implementing witnesses for a contract
Contract state constructor: expected 2 arguments (as invoked from Typescript), received 1initialState takes the constructor context plus each parameter of the Compact constructor; the private bid's takes the minimum bidPass the constructor's arguments after the context, as in Calling circuits from JavaScript
placeBid: expected 2 arguments (as invoked from Typescript), received 0An impure circuit takes the circuit context plus each parameter in the Compact signaturePass the context first, then the circuit's own arguments
Cannot read properties of undefined (reading 'currentQueryContext')The context was built by hand, or in the shape of an older runtime, so it has no callContextBuild contexts with createConstructorContext and createCircuitContext, as in Calling circuits from JavaScript
type error: ... expected value of type Uint<0..18446744073709551616> but received '250'A Uint<64> parameter received a string or a number; the wrapper checks every argument against its Compact type and rangePass a bigint, such as 250n
expected instance of ChargedStateledger() received a ContractState, which it does not acceptPass the state from a circuit call, or a deployed contract state's data field
failed assert: followed by a message from your own Compact sourceContract logic rejected the callNothing to fix in the harness: this is the rejection path a test asserts with await expect(...).rejects.toThrow(...)

Additional resources​