Skip to main content

Bulletin board contract

Just as with the counter DApp, there are separate subdirectories in the bboard-tutorial directory for the contract and the user interaction code.
Unlike the simple counter DApp, however, the bulletin board DApp demonstrates an additional useful separation: between user interface code and the underlying application logic that is independent of the user interface. Keeping the user interface separate from the application logic allows you to create a command-line version of the bulletin board in this part of the tutorial and a web-based version in a later part.

  • contract - contains the Compact source for the bulletin board contract, plus associated TypeScript
  • api - contains TypeScript source that implements the core behavior of the DApp, such as posting messages and taking them down, and depends on the contract code
  • bboard-cli - contains the command-line interface for the text-based DApp and depends on both the contract and api code.

Actually, the preceding description is not quite correct, because there is no Compact source file for the contract in the contract subdirectory yet. Your task is to write it.

tip

This page walks step by step through the design of the bulletin board contract. The finished contract appears at the end of the page. You must type or paste the Compact code into a file bboard.compact in the contract/src subdirectory of bboard-tutorial. Midnight recommends editing the contract using the Visual Studio Code extension for Compact, where you get nice colors and auto-completion of names.

To create a Midnight contract for the bulletin board scenario, you need to identify:

  • The components of the contract's public state
  • The visible operations that can be performed on the contract
  • The private data and operations, used by the visible operations in ways that are provably valid, but not shared publicly.

The public state of the contract and the transaction history of the public operations appear on the ledger of the Midnight blockchain. Anyone can verify them. The private data never has to leave the DApp user's computer.

Public ledger state on the blockchain

The public ledger state of the bulletin board consists of four values:

  1. A state: vacant or occupied
  2. A message
  3. A counter to identify which specific post is current
  4. A public token produced by the user who made the current post, but from which their private identity cannot be derived.

The third value is perhaps less obvious than the others; it corresponds to the 'that message' constraint mentioned on the previous page. When its value is 15, it says, 'The current message is the 15th post.' If you have ever implemented any sort of software lock, it will not surprise you that the correct behavior is to increment this counter whenever the board becomes vacant (not when it becomes occupied), because that is the point at which the current instance no longer corresponds to the just-removed post.

The fourth value should be a non-reversible hash of the poster's identity and the posting instance number (the counter). No one can figure out who posted the message from the token, but the user who created the post can reliably derive that token again to satisfy the identity-verification obligation.

Here is the type of that ledger state, specified in Midnight's Compact contract language:

export enum STATE { vacant, occupied }

ledger {
state: Cell[STATE];
message: Cell[Maybe[Opaque["string"]]];
instance: Counter;
poster: Cell[Bytes[32]];
constructor() {
ledger.state = STATE.vacant;
ledger.message = none[Opaque["string"]]();
ledger.instance.increment(1);
}
}

Some notes about the types used in this ledger declaration:

  • Notice that Compact includes support for declaring new types, such as the enumeration type that encodes the vacant or occupied state of the bulletin board. With the export modifier added to the declaration, the Compact compiler will generate TypeScript representations for the enumeration type and its values.
  • Compact's Cell type represents a single mutable cell, whose value can be updated by circuits.
  • The Counter type (automatically initialized to zero), can be incremented by circuits.
  • The Opaque type describes values whose internal structure is irrelevant to the contract.
  • The Maybe type describes values that may be absent. Its values are created using the some or none constructors.
  • The identity tokens are 256-bit hashes, which occupy 32 bytes.

The contract need not initialize explicitly the poster field when the ledger is constructed, because its value is not meaningful yet. The compiler guarantees that every field in the ledger will be initialized to some safe default value if the ledger constructor does not set a value explicitly.

Enforcing the contract: circuits

Remember, anyone who uses this bulletin board will be required to abide by its rules, but they must do the work of proving they satisfied the contract. When they make changes to the bulletin board, they submit proofs that they followed the rules, and observers can quickly verify their proofs. Such proofs, which observers can verify without access to the data that enabled the proof construction, are called zero-knowledge proofs, and they are implemented using mathematical circuits.

These ideas are not at all new; some of the important papers about zero-knowledge proofs were published in the 1980s. The more recent developments are advances in the way such proofs can be generated and verified automatically, without human intervention, and the way they can be combined with public blockchains.

One of Midnight's unique contributions to this space is to make the definition of zero-knowledge-based smart contracts and their supporting circuits accessible to general programmers.

Begin by writing the post operation as a Compact circuit definition. The main obligation to be satisfied in this part of the contract is the bulletin board's first rule: posting can occur only when the board is vacant.

export circuit post(message: Opaque["string"]): Void {
assert ledger.state == STATE.vacant
"Attempted to post to an occupied board";
ledger.poster = public_key(local_secret_key());
ledger.message = some[Opaque["string"]](message);
ledger.state = STATE.occupied;
}

A circuit definition is much like a function definition. It can specify input parameters and a return value. The current state of the ledger is also implicitly available in the circuit definition. To establish enforced contractual obligations, the definition uses the assert statement, which checks that some Boolean expression is true. If the expression is false, the transaction is aborted, reporting the failure using the message specified in the assert statement. The assert above checks that the board is vacant when someone tries to post a message (rule 1).

As with the enum STATE type, an export modifier has been added to this definition, so that the post circuit can be called from TypeScript.

What is this generated 'public key' that the post circuit writes into the ledger's poster field to identify the user posting the message? It is derived by hashing a string with the instance number of the post and the user's secret key, which is not sent over the network. The code above has used a call to a 'helper' circuit public_key, which is defined in the same contract. Here is its definition:

export circuit public_key(sk: Bytes[32]): Bytes[32] {
return persistent_hash(
persistent_hash(pad(32, "bboard:pk:"),
ledger.instance as Field as Bytes[32]),
sk);
}

Four notes about this definition:

  1. The persistent_hash function is defined in Compact's standard library.
  2. The cast from the Counter type of the ledger's instance field to the Bytes[32] needed for the hash function requires an intermediate cast through Field because there is no direct expansion to 32 bytes available. (See the permitted type casts in the reference documentation.)
  3. Typically, you would not export helper circuits like this one, because there would be no reason to call them from TypeScript. In this case, public_key is exported so that its value can be logged by the DApp, for debugging purposes.
  4. While this circuit is named public_key, its return value is not truly one side of a key pair from public key cryptography. Instead, zero-knowledge circuits can be understood as a kind of generalization of public key cryptography. The result of this circuit serves the same role as a public key, though, so its naming is intended to evoke that sense.

Accessing private state

So, how to retrieve secret key needed by the post circuit and passed to public_key? The function local_secret_key cannot be another circuit, because the values returned by circuits are publicly verifiable, and the value returned by this function should never appear in the ledger.

This kind of function is called a witness. Witness functions provide the API to the private state of a contract, as maintained by individual DApps that use the contract. The contract does not describe the definition of the witness; it merely declares the witness's existence. The DApp must implement it. Here is the declaration in the contract:

witness local_secret_key(): Bytes[32];

With all these tools at hand, you can write the take_down circuit, which enforces the rule that only the poster of the current post can take it down. Of course, it also makes no sense to take down a post from a vacant board, so the circuit checks that first:

export circuit take_down(): Opaque["string"] {
assert ledger.state == STATE.occupied
"Attempted to take down post from an empty board";
assert ledger.poster == public_key(local_secret_key())
"Attempted to take down post, but not the current poster";
const former_msg = ledger.message.value;
ledger.state = STATE.vacant;
ledger.instance.increment(1);
ledger.message = none[Opaque["string"]]();
return former_msg;
}

This circuit returns the message that was taken down, to demonstrate that public circuits can return values, too.

When a DApp submits a take_down transaction to the Midnight network, it does not include the private data that would allow other contract participants to check the second assert. Instead, it includes a verifiable proof that each assert has been checked. The Compact compiler generates all the material to make this possible 'behind the scenes,' without the DApp programmer ever having to write code that appears to generate and transmit proofs.

Compiling the contract

That's everything. Here is the complete contract.

include "std";

export enum STATE { vacant, occupied }

ledger {
state: Cell[STATE];
message: Cell[Maybe[Opaque["string"]]];
instance: Counter;
poster: Cell[Bytes[32]];
constructor() {
ledger.state = STATE.vacant;
ledger.message = none[Opaque["string"]]();
ledger.instance.increment(1);
}
}

witness local_secret_key(): Bytes[32];

export circuit post(message: Opaque["string"]): Void {
assert ledger.state == STATE.vacant
"Attempted to post to an occupied board";
ledger.poster = public_key(local_secret_key());
ledger.message = some[Opaque["string"]](message);
ledger.state = STATE.occupied;
}

export circuit take_down(): Opaque["string"] {
assert ledger.state == STATE.occupied
"Attempted to take down post from an empty board";
assert ledger.poster == public_key(local_secret_key())
"Attempted to take down post, but not the current poster";
const former_msg = ledger.message.value;
ledger.state = STATE.vacant;
ledger.instance.increment(1);
ledger.message = none[Opaque["string"]]();
return former_msg;
}

export circuit public_key(sk: Bytes[32]): Bytes[32] {
return persistent_hash(
persistent_hash(pad(32, "bboard:pk:"),
ledger.instance as Field as Bytes[32]),
sk);
}

In the contract subdirectory of bboard-tutorial, there is a build script to run the Compact compiler as part of the build, but it is instructive to run the compiler manually this time.

After saving the preceding contract as bboard.compact in bboard-tutorial/contract/src, compile it with the Compact compiler like this (assuming that you have added the Compact compiler's directory to your PATH and that your current directory is the contract directory when you run the command):

run-compactc.sh src/bboard.compact src/managed/bboard

You should see a message about the circuit complexity for each of the public circuits (post, take_down, and public_key) when you run the compiler. If you see an error message, check your code for mistakes. If you need help, contact the Midnight Developer Relations team or your fellow developers on Discord.

You can see the TypeScript API that the Compact compiler generated for the contract in src/managed/bboard/contract/index.d.cts. The DApp will rely on this API to deploy the contract and call the circuits.