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 TypeScriptapi
- contains TypeScript source that implements the core behavior of the DApp, such as posting messages and taking them down, and depends on thecontract
codebboard-cli
- contains the command-line interface for the text-based DApp and depends on both thecontract
andapi
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.
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:
- A state: vacant or occupied
- A message
- A counter to identify which specific post is current
- 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 }
export ledger state: Cell<STATE>;
export ledger message: Cell<Maybe<Opaque<"string">>>;
export ledger instance: Counter;
export ledger poster: Cell<Bytes<32>>;
constructor() {
state = STATE.vacant;
message = none<Opaque<"string">>();
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 thesome
ornone
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(new_message: Opaque<"string">): Void {
assert state == STATE.vacant
"Attempted to post to an occupied board";
poster = public_key(local_secret_key(), instance as Field as Bytes<32>);
message = some<Opaque<"string">>(new_message);
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<Vector<2, Bytes<32>>>(
[pad(32, 'bboard:pk:'), sk]);
}
Three notes about this definition:
- The
persistent_hash
function is defined in Compact's standard library. - 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. - 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 state == STATE.occupied
"Attempted to take down post from an empty board";
assert poster == public_key(local_secret_key(), instance as Field as Bytes<32>)
"Attempted to take down post, but not the current poster";
const former_msg = message.value;
state = STATE.vacant;
instance.increment(1);
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.
import CompactStandardLibrary;
export enum STATE { vacant, occupied }
export ledger state: Cell<STATE>;
export ledger message: Cell<Maybe<Opaque<"string">>>;
export ledger instance: Counter;
export ledger poster: Cell<Bytes<32>>;
constructor() {
state = STATE.vacant;
message = none<Opaque<"string">>();
instance.increment(1);
}
witness local_secret_key(): Bytes<32>;
export circuit post(new_message: Opaque<"string">): Void {
assert state == STATE.vacant
"Attempted to post to an occupied board";
poster = public_key(local_secret_key(), instance as Field as Bytes<32>);
message = some<Opaque<"string">>(new_message);
state = STATE.occupied;
}
export circuit take_down(): Opaque<"string"> {
assert state == STATE.occupied
"Attempted to take down post from an empty board";
assert poster == public_key(local_secret_key(), instance as Field as Bytes<32>)
"Attempted to take down post, but not the current poster";
const former_msg = message.value;
state = STATE.vacant;
instance.increment(1);
message = none<Opaque<"string">>();
return former_msg;
}
export circuit public_key(sk: Bytes<32>, instance: Bytes<32>): Bytes<32> {
return persistent_hash<Vector<2, Bytes<32>>>([persistent_hash<Vector<2, Bytes<32>>>([pad(32, "bboard:pk:"), instance]), 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
following the instructions provided in
Running Midnight Compact compiler
and that your current directory is the
contract
directory when you run the command):
compactc 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.