Skip to main content
For the complete documentation index, see llms.txt

Writing a contract

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 specifies the version of the language it is using, imports Midnight's standard libraries, and declares an enum for the state it may currently be in:

pragma language_version 0.26;

import CompactStandardLibrary;

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.

Each field in a smart contract's on-chain state is declared with a ledger declaration. A constructor can be used to initialize the ledger fields when the contract is deployed. In this case, the ledger declarations are as follows:

export ledger authority: Bytes<32>;

export ledger value: Uint<64>;

export ledger state: State;

export ledger round: Counter;

constructor(sk: Bytes<32>, v: Uint<64>) {
authority = disclose(publicKey(round, sk));
value = disclose(v);
state = State.SET;
}

circuit publicKey(round: Field, sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<3, Bytes<32>>>(
[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 the field names 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 publicKey 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 can also be 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(): Uint<64> {
assert(state == State.SET, "Attempted to get uninitialized value");
return 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 reference 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 secretKey(): Bytes<32>;

export circuit set(v: Uint<64>): [] {
assert(state == State.UNSET, "Attempted to set initialized value");
const sk = secretKey();
const pk = publicKey(round, sk);
authority = disclose(pk);
value = disclose(v);
state = State.SET;
}

export circuit clear(): [] {
assert(state == State.SET, "Attempted to clear uninitialized value");
const sk = secretKey();
const pk = publicKey(round, sk);
assert(authority == pk, "Attempted to clear without authorization");
state = State.UNSET;
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:

pragma language_version 0.26;

import CompactStandardLibrary;

enum State {
UNSET,
SET
}

export ledger authority: Bytes<32>;

export ledger value: Uint<64>;

export ledger state: State;

export ledger round: Counter;

constructor(sk: Bytes<32>, v: Uint<64>) {
authority = disclose(publicKey(round, sk));
value = disclose(v);
state = State.SET;
}

circuit publicKey(round: Field, sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<3, Bytes<32>>>(
[pad(32, "midnight:examples:lock:pk"), round as Bytes<32>, sk]);
}

export circuit get(): Uint<64> {
assert(state == State.SET, "Attempted to get uninitialized value");
return value;
}

witness secretKey(): Bytes<32>;

export circuit set(v: Uint<64>): [] {
assert(state == State.UNSET, "Attempted to set initialized value");
const sk = secretKey();
const pk = publicKey(round, sk);
authority = disclose(pk);
value = disclose(v);
state = State.SET;
}

export circuit clear(): [] {
assert(state == State.SET, "Attempted to clear uninitialized value");
const sk = secretKey();
const pk = publicKey(round, sk);
assert(authority == pk, "Attempted to clear without authorization");
state = State.UNSET;
round.increment(1);
}

Confidentiality​

It may not be immediately apparent what data is private, which private data remains confidential, and which private data, conversely, is disclosed. Fortunately, this is well-defined. Private data is defined to be the data contained within or derived from circuit arguments and witness return values. Private data is considered disclosed only when it is passed to a ledger operation, when it is returned from an exported circuit, when it is passed to another contract via a cross-contract call, when it is used to compute the boolean value of an assert subexpression, and when it is used to determine the flow of control that leads to a conditionally executed ledger operation, cross-contract call, or exported circuit return. One exception is that data contained within the arguments to an exported circuit is not considered disclosed when it is returned from that circuit, because it is necessarily already known to the caller. In most cases, disclosure of private data must be explicitly declared via a disclose() wrapper; the only exception here is that the use of private data to compute the value of an assert subexpression is allowed to be implicitly disclosed, since it is so often used to assert properties of private data. The contract often has considerable leeway regarding placement of disclose() wrappers; often, but not always, the best place is as close to the point of disclosure as possible. It is generally not a good idea to disclose data too early where it might be marked for disclosure on some paths where it should not be disclosed.

In the example above, the value of the v parameter is considered to contain private data, and where it is stored in the ledger value field, this disclosure must be and is explicitly declared via disclose(v). The value of the secretKey witness is also considered to contain private data, and its disclusure is also explicitly declared at the point where the persistent hash of a struct containing it is stored in the ledger authority field. The value of secretKey() is also implicitly disclosed via the assert in clear.

While the secret key returned by secretKey() is not kept entirely confidential, partial confidentially is maintained because only the hash of a value containing the secret key is disclosed.

This is also the reason why round parameter is included with the secret key in the disclosed value. Exposing a hash of the secret key by itself 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.

The CompactStandardLibrary module provides the following circuits for such uses:

circuit transientHash<T>(value: T): Field;
circuit transientCommit<T>(value: T, rand: Field): Field;
circuit persistentHash<T>(value: T): Bytes<32>;
circuit persistentCommit<T>(value: T, 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.

Proof of in-circuit computations​

Computation that is not done in circuit is proven be "correct" in the sense that any attempt by an application to employ a modified computation will result in a proof that fails verification against the verifier key stored at contract deployment time. All code appearing textually within a Compact circuit is in circuit; code run outside of the circuit, including within a witness function called from a circuit, is not considered in circuit.

Next steps​

The Compact reference provides a compelete and 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.