Skip to main content

Writing a contract

Midnight comes with its own programming language, Compact, which enables you to write smart contracts as described in the overview. The Compact compiler outputs zero-knowledge circuits that are used to prove the correctness of interactions with the ledger. This page walks through the construction of a simple smart contract, which manages a publicly accessible value and supports the operations get, set, and clear. Because the value is public, anyone can call get, but if the value is currently set, only the user that last called set can clear it, and it must be cleared before setting it again.

To start, the contract includes Midnight's standard libraries and declares an enum for the state it may currently be in:

include "std";
enum State { unset, set }

In addition to the enum declaration, custom data can also be defined with structs. Details can be found in the language reference.

The ledger section

A key part of a Compact smart contract is the ledger section, which describes the state kept on-chain. This example stores a key that can be used to identify the user who is permitted to clear the value, the value itself (this example uses a 64-bit unsigned integer), and what state the contract is in.

In addition to these, it is necessary to add a round counter, which is useful to retain anonymity, as discussed below.

The ledger section consists of a declaration of the different fields on a smart contract, as well as a constructor that initializes them. In this case, it looks as follows:

ledger {
authority: Cell[Bytes[32]];
value: Cell[Unsigned Integer[64]];
state: Cell[State];
round: Counter;
constructor(sk: Bytes[32], value: Unsigned Integer[64]) {
ledger.authority = public_key(ledger.round, sk);
ledger.value = value;
ledger.state = State.set;
}
}

circuit public_key(round: Field, sk: Bytes[32]): Bytes[32] {
return persistent_hash(
persistent_hash(
pad(32, "midnight:examples:lock:pk"),
round as Bytes[32]),
sk);
}

In addition to the ledger section, the constructor also demonstrates basic interaction with the state contained in it, using ledger.<field> to refer to the items in the ledger's state. Many ledger types also support operations as demonstrated in clear.

The circuit definitions

The example above already demonstrates use of a circuit to calculate the public_key of a user. A circuit in Compact is equivalent to a function in many programming languages, but it is restricted to fixed computational bounds at compile time. A smart contract's circuits are also its main entry points; they are what users can call directly in transactions. Of the three entry points mentioned above, get is unrestricted and is simply implemented as follows:

export circuit get(): Unsigned Integer[64] {
assert ledger.state == State.set
"Attempted to get uninitialized value";
return ledger.value;
}

Here export marks this circuit as an entry point to the smart contract, and assert ensures that it can only be used when the contract is in the correct state. The language referece details permissible contents of circuits.

Local state and computations

The third context mentioned was the local machine of the user. This is explicitly programmable in the form of the DApp running on the user's machine. Compact can 'call out' to the local context through witnesses[^1], which are declared in a similar way to circuits. In this case, retrieving a user's secret key requires such a witness, because the secret must be kept local to a user's machine.

The code for this follows:

witness secret_key(): Bytes[32];

export circuit set(value: Unsigned Integer[64]): Void {
assert ledger.state == State.unset
"Attempted to set initialized value";
const sk = secret_key();
const pk = public_key(ledger.round, sk);
ledger.authority = pk;
ledger.value = value;
ledger.state = State.set;
}

export circuit clear(): Void {
assert ledger.state == State.set
"Attempted to clear uninitialized value";
const sk = secret_key();
const pk = public_key(ledger.round, sk);
assert ledger.authority == pk
"Attempted to clear without authorization";
ledger.state = State.unset;
ledger.round.increment(1);
}

Note that the witness is not implemented in the Compact source code. Instead, the implementation is the responsibility of the TypeScript code of the DApp. It's important to note that each user could reasonably use a different implementation for the witness, so its results cannot be trusted inherently by the contract.

The full contract

All put together, the full example is:

include "std";
enum State { unset, set }

ledger {
authority: Cell[Bytes[32]];
value: Cell[Unsigned Integer[64]];
state: Cell[State];
round: Counter;
constructor(sk: Bytes[32], value: Unsigned Integer[64]) {
ledger.authority = public_key(ledger.round, sk);
ledger.value = value;
ledger.state = State.set;
}
}

circuit public_key(round: Field, sk: Bytes[32]): Bytes[32] {
return persistent_hash(
persistent_hash(
pad(32, "midnight:examples:lock:pk"),
round as Bytes[32]),
sk);
}

export circuit get(): Unsigned Integer[64] {
assert ledger.state == State.set
"Attempted to get uninitialized value";
return ledger.value;
}

witness secret_key(): Bytes[32];

export circuit set(value: Unsigned Integer[64]): Void {
assert ledger.state == State.unset
"Attempted to set initialized value";
const sk = secret_key();
const pk = public_key(ledger.round, sk);
ledger.authority = pk;
ledger.value = value;
ledger.state = State.set;
}

export circuit clear(): Void {
assert ledger.state == State.set
"Attempted to clear uninitialized value";
const sk = secret_key();
const pk = public_key(ledger.round, sk);
assert ledger.authority == pk
"Attempted to clear without authorization";
ledger.state = State.unset;
ledger.round.increment(1);
}

Basic confidentiality

It may not be immediately apparent what is held confidential in this example and what is enforced in the contract. Thankfully, both are well-defined:

  • all data that is not a ledger.<x> value and is not passed as a ledger.<x>.<operation> argument or return value is kept confidential
  • all computation that is not done in a witness function is enforced to be correct.

In particular, observe that this keeps the secret_key output confidential, while enforcing that its hash is the correct value in the case of clear.

This is also the reason for the round parameter: The pk "public key" isn't confidential, and would allow linkability between the same user publishing data in multiple rounds. By adding a round parameter into the public key computation, this linkability is broken.

Despite the terms "secret key" and "public key", these two keys are not public key cryptography: they are simply a binary string and its hash. This is due to zero-knowledge circuits being able to have similar effects to digital signatures, relying only on the preimage resistance of hashes.

This pattern of hashing an arbitrary binary string and using it as a key is quite powerful. A similar concept that can be very useful is the use of commitment schemes, where arbitrary data is hashed together with a random nonce. The result can be safely placed into the ledger's state, without revealing the original data. (Note that the nonce must not be reused. If it is, you can link the commitments with the same nonces and values.) At a later point, the commitment can be "opened" by revealing the value and nonce, or a contract can simply prove (assert) that it has the correct value and nonce, without ever revealing them.

Compact's std library provides the following functions for such uses:

  export circuit transient_hash(x: Field, y: Field): Field;
export circuit transient_commit[a](value: a, rand: Field): Field;
export circuit persistent_hash(x: Bytes[32], y: Bytes[32]): Bytes[32];
export circuit persistent_commit[a](value: a, rand: Bytes[32]): Bytes[32];

The *_hash variants are the basic hash function, with *_commit being a commitment function to arbitrary data. The transient_* functions should only be used when the values are not kept in state, while persistent_* outputs being suitable for storage in a contract's ledger state.

Next steps

This section continues with a more detailed overview of the Compact language. Alternatively, you may wish to jump to a more detailed example that showcases some more interesting things you can do with a DApp on Midnight. While this section has focused on the Compact language, the section about how Midnight works provides more detail about Midnight's ledger and how it functions.

[^1] The name witness comes from zero-knowledge literature; the etymology is roughly that it's the evidence you need to believe a statement. In this example, it's the evidence you need to believe that a clear was permissible.