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 clear
ed before set
ting 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
struct
s. 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 circuit
s 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 circuit
s.
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 aledger.<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.