Build the bulletin board contract
This tutorial shows you how to build a privacy-preserving bulletin board smart contract on the Midnight blockchain.
By the end of this tutorial, you will:
- Create a Compact smart contract with public ledger state.
- Implement witness functions for accessing private data.
- Use cryptographic commitments to protect user identity.
- Define circuits that enforce access control rules.
- Compile the contract into Zero Knowledge (ZK) circuits.
- Understand the generated TypeScript API.
The bulletin board contract allows users to post and remove messages while maintaining privacy. Only the original poster can remove their message, and this is enforced through ZK proofs without revealing the poster's identity on-chain.
Prerequisites
Before you begin, ensure that you have:
- Compact toolchain installed: For instructions, refer to the install the toolchain guide
- Proof server running: For instructions, refer to the run the proof server guide
- Node.js version 22 or higher: Verify with
node --version - TypeScript knowledge: Familiarity with types, interfaces, and async programming
- Command-line proficiency: Comfortable with terminal operations
Set up the project
Create the project root and contract directories:
mkdir -p example-bboard/contract/src
cd example-bboard/contract
Your directory structure should now look like this:
example-bboard/
└── contract/
└── src/
Write the smart contract
This section explains the process of writing the smart contract and understanding the key concepts.
Create the contract file
Create contract/src/bboard.compact:
touch src/bboard.compact
Open this file in your code editor.
Add the language version
The pragma language_version directive specifies which version of Compact your contract uses:
pragma language_version 0.20;
This directive:
- Locks your contract to a specific Compact version
- Prevents breaking changes in future compiler versions
- Ensures consistent compilation across development environments
Import the standard library
Import Compact's standard library for built-in types and functions:
pragma language_version 0.20;
import CompactStandardLibrary;
The CompactStandardLibrary provides access to built-in types and functions in Compact, such as Maybe for optional values, Counter for tracking sequences, persistentHash for cryptographic commitments, some and none constructors for Maybe values.
To learn more about what's available in the standard library, see the Compact standard library reference.
Define the board state enum
The bulletin board has two possible states. Define an enumeration type to represent them:
pragma language_version 0.20;
import CompactStandardLibrary;
export enum State {
VACANT,
OCCUPIED
}
This enumeration:
exportmakes the enum accessible from TypeScriptStateis the enum type nameVACANTrepresents an empty board (value 0)OCCUPIEDrepresents a board with a message (value 1)
Define the ledger state
The ledger represents the public, on-chain state of your contract. For the bulletin board, you need four pieces of public information:
pragma language_version 0.20;
import CompactStandardLibrary;
export enum State {
VACANT,
OCCUPIED
}
export ledger state: State;
export ledger message: Maybe<Opaque<'string'>>;
export ledger sequence: Counter;
export ledger owner: Bytes<32>;
Each ledger field serves a specific purpose:
state: Tracks whether the board is vacant or occupiedmessage: Stores the current message as an optional opaque stringsequence: A counter that increments each time a message is taken down, creating unique commitments for each posting cycle and preventing replay attacksowner: Stores a cryptographic commitment to the poster's identity as a 32-byte hash
The Maybe type indicates optional values. A message is none when the board is vacant and some(value) when occupied. The Opaque<'string'> type represents string data whose internal structure is irrelevant to the contract.
Create the constructor
The constructor initializes the ledger state when the contract is deployed:
// ... previous code ...
constructor() {
state = State.VACANT;
message = none<Opaque<'string'>>();
sequence.increment(1);
}
This constructor makes deliberate choices about what to explicitly initialize versus what to leave as default:
state = State.VACANT: Explicitly set to make the contract independent of enum orderingmessage = none<Opaque<'string'>>(): Explicitly set to decouple from the standard library'sMaybedefault implementationsequence.increment(1): Increments from 0 to 1, so posts start at sequence number 1owner: Uninitialized, using the language-guaranteed default of 32 zero bytes
The pattern: explicitly initialize values that depend on library or enum definitions, but rely on language-defined defaults when they're guaranteed by the specification.
Fields not explicitly initialized in the constructor receive default values for their type as defined by the Compact language specification.
Declare the witness function
Before defining the circuits, declare a witness function:
// ... previous code ...
witness localSecretKey(): Bytes<32>;
This declaration:
witnessmarks this as a function implemented in TypeScript or JavaScript that performs arbitrary computation.localSecretKeyreturns a 32-byte value of the user's secret key.- The return value is private by default and doesn't appear on-chain or in public ledger state.
Witnesses enable privacy-preserving computation. They can perform any computation, such as accessing private state or generating values, and return results that circuits use for proof generation.
Create the post circuit
The post circuit allows users to post messages to the vacant board:
// ... previous code ...
export circuit post(newMessage: Opaque<'string'>): [] {
assert(state == State.VACANT, "Attempted to post to an occupied board");
owner = disclose(publicKey(localSecretKey(), sequence as Field as Bytes<32>));
message = disclose(some<Opaque<'string'>>(newMessage));
state = State.OCCUPIED;
}
This circuit performs several operations:
- Validate board state:
assert(state == State.VACANT, ...)ensures posting only happens when the board is vacant. - Generate commitment: Calls
publicKey()helper circuit to create a cryptographic commitment from the secret key and current sequence number. This is what allows only the original poster to take down their message. - Disclose: Wraps the commitment in
disclose()to tell the compiler this value is safe to reveal on-chain. - Store message: Sets
messagetosome(newMessage)and wraps it indisclose()to explicitly reveal it. - Update state: Changes
state = State.OCCUPIEDto mark the board as occupied.
The disclose keyword is critical for security.
By default, Compact prevents computed values from being assigned to public ledger fields.
You must explicitly wrap values in disclose() to mark them as safe to reveal on-chain,
ensuring you don't accidentally leak private data.
The sequence counter is cast twice (sequence as Field as Bytes<32>) because you cannot directly cast from Counter to Bytes<32>. The intermediate Field cast provides a compatible type path.
Create the takeDown circuit
The takeDown circuit allows users to remove their own messages:
// ... previous code ...
export circuit takeDown(): Opaque<'string'> {
assert(state == State.OCCUPIED, "Attempted to take down post from an empty board");
assert(owner == publicKey(localSecretKey(), sequence as Field as Bytes<32>), "Attempted to take down post, but not the current owner");
const formerMsg = message.value;
state = State.VACANT;
sequence.increment(1);
message = none<Opaque<'string'>>();
return formerMsg;
}
This circuit performs several operations:
- Validate board state: Ensures the board is occupied before attempting removal
- Regenerate commitment: Calls
publicKey()with the current user's secret key and sequence number - Verify ownership: Compares the regenerated commitment to the stored
ownervalue - Extract message: Uses
message.valueto access the inner value from theMaybetype - Update state: Changes
statetoState.VACANT - Increment sequence: Advances the counter so the next post uses sequence number 2, 3, and so on
- Clear message: Resets
messagetonone - Return message: Returns the taken-down message to the caller
The second assert is where privacy meets access control. The user proves they can regenerate the stored commitment without revealing their secret key. The ZK proof validates this assertion without exposing the private data.
Note that message is cleared after extracting its value. This ensures the board is ready for the next post.
Create the publicKey helper circuit
The publicKey circuit generates a cryptographic commitment to the poster's identity:
// ... previous code ...
export circuit publicKey(sk: Bytes<32>, sequence: Bytes<32>): Bytes<32> {
return persistentHash<Vector<3, Bytes<32>>>([pad(32, "bboard:pk:"), sequence, sk]);
}
This helper circuit:
- Uses
pad(32, "bboard:pk:")to create a domain separator - Creates a vector of three 32-byte values to hash together
- Uses
persistentHashfrom the standard library, which implements SHA-256 hashing - Takes an input value (
sk) and sequence number as parameters - Returns a 32-byte cryptographic hash
The commitment has critical properties:
- Domain separation: The
"bboard:pk:"prefix prevents hash collisions with other uses of the secret key - One-way: SHA-256 is cryptographically non-reversible, meaning you cannot discover the input from the hash output
- Deterministic: The same inputs always produce the same output
- Unique per post: The sequence number ensures each post has a different commitment
The domain separator is a security best practice. It ensures that hashes generated for the bulletin board cannot be confused with hashes generated for other purposes, even if they use the same secret key.
Your complete bulletin board contract should now look like this:
pragma language_version 0.20;
import CompactStandardLibrary;
export enum State {
VACANT,
OCCUPIED
}
export ledger state: State;
export ledger message: Maybe<Opaque<'string'>>;
export ledger sequence: Counter;
export ledger owner: Bytes<32>;
constructor() {
state = State.VACANT;
message = none<Opaque<'string'>>();
sequence.increment(1);
}
witness localSecretKey(): Bytes<32>;
export circuit post(newMessage: Opaque<'string'>): [] {
assert(state == State.VACANT, "Attempted to post to an occupied board");
owner = disclose(publicKey(localSecretKey(), sequence as Field as Bytes<32>));
message = disclose(some<Opaque<'string'>>(newMessage));
state = State.OCCUPIED;
}
export circuit takeDown(): Opaque<'string'> {
assert(state == State.OCCUPIED, "Attempted to take down post from an empty board");
assert(owner == publicKey(localSecretKey(), sequence as Field as Bytes<32>), "Attempted to take down post, but not the current owner");
const formerMsg = message.value;
state = State.VACANT;
sequence.increment(1);
message = none<Opaque<'string'>>();
return formerMsg;
}
export circuit publicKey(sk: Bytes<32>, sequence: Bytes<32>): Bytes<32> {
return persistentHash<Vector<3, Bytes<32>>>([pad(32, "bboard:pk:"), sequence, sk]);
}
Compile the contract
Compilation transforms your Compact code into ZK circuits and generates TypeScript APIs for interacting with the contract.
Run the compiler
From the contract directory, compile the contract:
compact compile src/bboard.compact src/managed/bboard
This command has three parts:
compact compileinvokes the Compact compiler.src/bboard.compactspecifies the source file to compile.src/managed/bboardspecifies the output directory for generated files.
You should see output similar to:
Compiling 2 circuits:
circuit "post" (k=13, rows=4569)
circuit "takeDown" (k=13, rows=4580)
Overall progress [====================] 2/2
Examine the generated files
After compilation, the src/managed/bboard directory contains:
src/managed/bboard/
├── contract/
│ ├── index.d.ts # Type definitions
│ ├── index.js # JavaScript implementation
│ └── index.js.map
├── keys/ # Cryptographic keys
│ ├── post.prover
│ ├── post.verifier
│ ├── takeDown.prover
│ ├── takeDown.verifier
├── zkir/ # ZK Intermediate Representation
│ ├── post.zkir
│ ├── post.bzkir
│ ├── takeDown.zkir
│ ├── takeDown.bzkir
└── compiler/ # Compiler metadata
└── contract-info.json
Each directory serves the following purpose:
contract/: Contains the generated TypeScript API and JavaScript implementation that your DApp uses to interact with the contractkeys/: Cryptographic keys used for generating and verifying ZK proofs for each circuitzkir/: Intermediate circuit representations used by the proof servercompiler/: Metadata about circuits, types, and structure in JSON format
Understand the generated API
The Compact compiler generates TypeScript definitions that correspond to your contract code. Open managed/bboard/contract/index.d.ts to examine the generated types.
State type
The State enum from your Compact code becomes a TypeScript enum:
export enum State {
VACANT = 0,
OCCUPIED = 1
}
This allows TypeScript code to reference board states type-safely using State.VACANT and State.OCCUPIED.
Circuit types
The Circuits type defines the callable functions:
export type Circuits<PS> = {
post(
context: __compactRuntime.CircuitContext<PS>,
newMessage: string
): __compactRuntime.CircuitResults<PS, []>;
takeDown(
context: __compactRuntime.CircuitContext<PS>
): __compactRuntime.CircuitResults<PS, string>;
publicKey(
context: __compactRuntime.CircuitContext<PS>,
sk: Uint8Array,
sequence: Uint8Array
): __compactRuntime.CircuitResults<PS, Uint8Array>;
}
Each circuit method:
- Corresponds to an exported circuit in your Compact code
- Takes a
CircuitContextthat provides access to ledger state and witness functions - Accepts parameters matching the Compact circuit parameters
- Returns
CircuitResultscontaining theProofData, which is sent to the proof server to generate the ZK proof - Uses appropriate JavaScript types (
string,Uint8Array) for Compact types
Ledger types
The Ledger type defines the public state structure:
export type Ledger = {
readonly state: State;
readonly message: { is_some: boolean, value: string };
readonly sequence: bigint;
readonly owner: Uint8Array;
}
Each field:
- Corresponds to a ledger declaration in your Compact code
- Uses JavaScript types:
Stateenum,string,bigint,Uint8Array - Represents
Maybetypes as objects withis_some: booleanandvalueproperties - Is marked as
readonly, meaning state modifications can only happen through circuit calls
To use the Maybe type annotation in your TypeScript code for the message ledger field, export it from your Compact contract:
export { Maybe };
Witness types
The Witnesses type defines the required witness implementations:
export type Witnesses<PS> = {
localSecretKey(context: __compactRuntime.WitnessContext<Ledger, PS>): [PS, Uint8Array];
}
This type:
- Corresponds to the
witness localSecretKey()declaration in your Compact code - Takes a
WitnessContextproviding access to ledger state, private state, and contract address - Returns a tuple
[PS, Uint8Array]containing the updated private state and the 32-byte secret key - Must be implemented by your DApp to provide private data during circuit execution
Contract type
The Contract class ties everything together:
export declare class Contract<PS = any, W extends Witnesses<PS> = Witnesses<PS>> {
witnesses: W;
circuits: Circuits<PS>;
impureCircuits: ImpureCircuits<PS>;
constructor(witnesses: W);
initialState(context: __compactRuntime.ConstructorContext<PS>): __compactRuntime.ConstructorResult<PS>;
}
The Contract class provides the main interface for interacting with your compiled contract:
- Uses type parameters
PSfor private state andWfor witnesses - Provides
circuitsfor pure circuit functions - Provides
impureCircuitsfor circuits that interact with witnesses - Accepts witness implementations in the constructor
- Initializes contract state through
initialState, which calls your constructor
Implement witness functions
The bulletin board contract requires a witness implementation to provide access to the user's secret key during circuit execution.
Create the witnesses file
Create contract/src/witnesses.ts:
import { Ledger } from "./managed/bboard/contract/index.js";
import { WitnessContext } from "@midnight-ntwrk/compact-runtime";
/* **********************************************************************
* The only hidden state needed by the bulletin board contract is
* the user's secret key. Some of the library code and
* compiler-generated code is parameterized by the type of our
* private state, so we define a type for it and a function to
* make an object of that type.
*/
export type BBoardPrivateState = {
readonly secretKey: Uint8Array;
};
export const createBBoardPrivateState = (secretKey: Uint8Array) => ({
secretKey,
});
/* **********************************************************************
* The witnesses object for the bulletin board contract is an object
* with a field for each witness function, mapping the name of the function
* to its implementation.
*
* The implementation of each function always takes as its first argument
* a value of type WitnessContext<L, PS>, where L is the ledger object type
* that corresponds to the ledger declaration in the Compact code, and PS
* is the private state type, like BBoardPrivateState defined above.
*
* A WitnessContext has three
* fields:
* - ledger: T
* - privateState: PS
* - contractAddress: string
*
* The other arguments (after the first) to each witness function
* correspond to the ones declared in Compact for the witness function.
* The function's return value is a tuple of the new private state and
* the declared return value. In this case, that's a BBoardPrivateState
* and a Uint8Array (because the contract declared a return value of Bytes<32>,
* and that's a Uint8Array in TypeScript).
*
* The localSecretKey witness does not need the ledger or contractAddress
* from the WitnessContext, so it uses the parameter notation that puts
* only the binding for the privateState in scope.
*/
export const witnesses = {
localSecretKey: ({
privateState,
}: WitnessContext<Ledger, BBoardPrivateState>): [
BBoardPrivateState,
Uint8Array,
] => [privateState, privateState.secretKey],
};
This code defines:
BBoardPrivateState: The private state type with asecretKeyfield of typeUint8Array.createBBoardPrivateState(): Helper function to initialize private state with a secret key.witnesses.localSecretKey: Implementation that receives aWitnessContext<Ledger, BBoardPrivateState>and returns a tuple of[BBoardPrivateState, Uint8Array]. The function extracts theprivateStatefrom the context and returns both the unchanged private state and the secret key.
The witness function receives a WitnessContext parameter that provides access to the ledger state, private state, and contract address. The Compact runtime passes this context automatically during circuit execution.
The WitnessContext type is part of the Compact Runtime API. For detailed information about witness contexts and other runtime types, refer to the Compact runtime API documentation.
Create the index file
Create contract/src/index.ts to re-export the contract API:
export * from "./managed/bboard/contract/index.js";
export * from "./witnesses";
import * as CompiledBBoardContract from "./managed/bboard/contract/index.js";
import * as Witnesses from "./witnesses";
This file serves as the main entry point for the bulletin board contract. It re-exports all types and functions from the generated contract code and witness implementations, providing a single import point for consuming applications.
Initialize the npm package
The contract needs a package.json file to manage dependencies, define build scripts, and package the contract for use in DApps. This makes it easy to compile, build, and distribute the contract as a reusable module.
Create a package.json file in the contract directory:
npm init -y
This generates a basic package.json file with default values, which you'll customize in a later step to add compilation scripts and package metadata.
Configure TypeScript
Create a tsconfig.json file in the contract directory:
{
"include": ["src/**/*.ts"],
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"lib": ["ESNext"],
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strict": true,
"isolatedModules": true,
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
The key configuration options are:
targetandmodule: Set to ES2022 for modern JavaScript featuresdeclaration: Generates.d.tstype definition files for TypeScript consumersoutDir: Compiled JavaScript files go to./distrootDir: Source TypeScript files are in./srcstrict: Enables strict type checking for better code quality
Add build scripts
Update your contract/package.json to include build scripts:
{
"name": "@midnight-ntwrk/bboard-contract",
"version": "0.1.0",
"license": "Apache-2.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.js",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"scripts": {
"clean": "rm -rf dist managed",
"compile:compact": "compact compile src/bboard.compact src/managed/bboard",
"compile:typescript": "tsc",
"build": "npm run clean && npm run compile:compact && npm run compile:typescript && cp -Rf ./src/managed ./dist/managed && cp ./src/bboard.compact ./dist"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.8.0"
}
}
Each script serves the following purpose:
clean: Removes compiled output for a fresh buildcompile:compact: Runs the Compact compiler to generate circuits and TypeScript APIcompile:typescript: Compiles TypeScript to JavaScriptbuild: Executes all compilation steps in order and copies necessary files
Build the contract
Run the complete build process:
npm install
npm run build
This command:
- Installs dependencies (TypeScript compiler and Node.js types)
- Cleans previous build artifacts
- Compiles the Compact contract to circuits and TypeScript
- Compiles TypeScript to JavaScript
- Generates type definition files
- Copies managed code and source contract to the
distdirectory
You should see output from both the Compact compiler and TypeScript compiler. If successful, you'll have:
dist/managed/bboard/: Generated contract codedist/: Compiled JavaScript and type definitionsdist/bboard.compact: The source contract file
Next steps
Now that you've built and compiled the bulletin board contract:
- Build the CLI: Continue to build the bulletin board CLI to create an interactive command-line interface
- Test the contract: Add unit tests in
src/test/to verify circuit behavior and commitment generation