Skip to main content

Anonymous Message Board Tutorial

This example shows you how to put building zkApp ideas into practice as you walk through designing and implementing of a semi-anonymous messaging protocol.

In this tutorial, you build a smart contract that allows users to publish messages semi-anonymously.

  • The contract allows a specific set of users to create new messages but does not disclose which user creates the message.
  • This semi-anonymous messaging leverages one aspect of a person's identity without revealing exactly who they are.

An example use case for this semi-anonymous contract is to enable a DAO member to make credible statements on behalf of their DAO without revealing their specific individual identity.

Prerequisites

Ensure your environment meets the Prerequisites for zkApp Developer Tutorials.

In particular, make sure you have the zkApp CLI installed:

$ npm install -g zkapp-cli

This tutorial has been tested with:

Create the message-board project

  1. Create or change to a directory where you have write privileges.

  2. Create a project using the zk project command:

    $ zk project --ui none message-board

    The zk project command creates a directory with a new project template that is fully set up and ready for local development. Like all zk projects, a git repository is initialized in the project directory. By convention, the main branch is the default branch.

  3. Change to the project directory:

    cd message-board

    For this tutorial, you run commands from the root of the message-board directory as you work in the src directory on files that contain the TypeScript code for the smart contract.

    Each time you make updates, then build or deploy, the TypeScript code is compiled into JavaScript in the build directory. See the included README file with usage instructions.

Prepare the project

Example smart contract files come with the new project, you can delete them if you want.

  1. Optional: Delete the example files not used in this tutorial:

    $ rm -rf src/*
  2. To create the src/message.ts file:

    $ zk file message
  3. Copy the entire contents of the message.ts example file into your local message-board/src/message.ts file.

    import {
    Field,
    SmartContract,
    state,
    State,
    method,
    PrivateKey,
    PublicKey,
    Poseidon,
    } from 'o1js';

    // These private keys are exported so that experimenting with the contract is
    // easy. Three of them (the Bobs) are used when the contract is deployed to
    // generate the public keys that are allowed to post new messages. Jack's key
    // is never added to the contract. So he won't be able to add new messages. In
    // real life, we would only use the Bobs' public keys to configure the contract,
    // and only they would know their private keys.

    export const users = {
    Bob: PrivateKey.fromBase58(
    'EKFAdBGSSXrBbaCVqy4YjwWHoGEnsqYRQTqz227Eb5bzMx2bWu3F'
    ),
    SuperBob: PrivateKey.fromBase58(
    'EKEitxmNYYMCyumtKr8xi1yPpY3Bq6RZTEQsozu2gGf44cNxowmg'
    ),
    MegaBob: PrivateKey.fromBase58(
    'EKE9qUDcfqf6Gx9z6CNuuDYPe4XQQPzFBCfduck2X4PeFQJkhXtt'
    ), // This one says duck in it :)
    Jack: PrivateKey.fromBase58(
    'EKFS9v8wxyrrEGfec4HXycCC2nH7xf79PtQorLXXsut9WUrav4Nw'
    ),
    };

    export class Message extends SmartContract {
    // On-chain state definitions
    @state(Field) message = State<Field>();
    @state(Field) messageHistoryHash = State<Field>();
    @state(PublicKey) user1 = State<PublicKey>();
    @state(PublicKey) user2 = State<PublicKey>();
    @state(PublicKey) user3 = State<PublicKey>();

    init() {
    // Define initial values of on-chain state
    this.user1.set(users['Bob'].toPublicKey());
    this.user2.set(users['SuperBob'].toPublicKey());
    this.user3.set(users['MegaBob'].toPublicKey());
    this.message.set(Field(0));
    this.messageHistoryHash.set(Field(0));
    }

    @method async publishMessage(message: Field, signerPrivateKey: PrivateKey) {
    // Compute signerPublicKey from signerPrivateKey argument
    const signerPublicKey = signerPrivateKey.toPublicKey();

    // Get approved public keys
    const user1 = this.user1.get();
    const user2 = this.user2.get();
    const user3 = this.user3.get();

    // Assert that signerPublicKey is one of the approved public keys
    signerPublicKey
    .equals(user1)
    .or(signerPublicKey.equals(user2))
    .or(signerPublicKey.equals(user3))
    .assertEquals(true);

    // Update on-chain message state
    this.message.set(message);

    // Compute new messageHistoryHash
    const oldHash = this.messageHistoryHash.get();
    const newHash = Poseidon.hash([message, oldHash]);

    // Update on-chain messageHistoryHash
    this.messageHistoryHash.set(newHash);
    }
    }

This code serves as the scaffolding for the rest of the tutorial and contains a smart contract called message with two methods:

  • init() - Similar to the constructor in Solidity, it's where you define any set up that needs to happen before users begin interacting with the contract.

  • publishMessage() - The method that users invoke when they want to create a new message.

    The @method decorator tells o1js to:

    • Allow users to call this method
    • Generate a zero knowledge proof (ZKP) of its execution

Define on-chain state

Every Mina smart contract includes eight on-chain state variables that each store almost 256 bits of information. In more complex smart contracts, these state variables can store commitments to off-chain storage (for example, commitments for the hash of a file, the root of a Merkle tree, and so on).

For simplicity, this tutorial stores everything on-chain.

note

General purpose off-chain storage libraries are appropriate only for development. See Tutorial 6: Off-Chain Storage.

In this smart contract, one state variable stores the last message. Another stores the hash of all the previous messages so a frontend can validate message history. Three more state variables can store user public keys.

It's possible to store additional public keys by Merkelizing them, but for simplicity this tutorial uses only three keys:

export class Message extends SmartContract {
// On-chain state definitions
@state(Field) message = State<Field>();
@state(Field) messageHistoryHash = State<Field>();
@state(PublicKey) user1 = State<PublicKey>();
@state(PublicKey) user2 = State<PublicKey>();
@state(PublicKey) user3 = State<PublicKey>();

The @state(Field) decorator tells o1js that the variable is stored on-chain as a Field type.

For practical purposes, the Field type is similar to the uint256 type in Solidity. It can store large integers and addition, subtraction, and multiplication all work as expected. The only caveats are division and what happens in the event of an overflow. To learn more about finite fields, see Finite field arithmetic. It is not required to understand exactly how field arithmetic works for this tutorial.

o1js also provides UInt32, UInt64, and Int64 types. All o1js types are composed of the Field type, including PublicKey as shown in the previous example.

Define the init() method

The init method is similar to the constructor in Solidity. It's where you define any setup that needs to happen before users begin interacting with the contract. In this case, set the public keys of users who can post and initialize message and messageHistoryHash as zero. The front end interprets the zero value to mean that no messages have been posted yet.

init() {
// Define initial values of on-chain state
this.user1.set(users['Bob'].toPublicKey());
this.user2.set(users['SuperBob'].toPublicKey());
this.user3.set(users['MegaBob'].toPublicKey());
this.message.set(Field(0));
this.messageHistoryHash.set(Field(0));
}

Define publishMessage()

The publishMessage method allows an approved user to publish a message. The @method decorator makes this method callable by users so that they can interact with the smart contract.

For this example, pass in message and signerPrivateKey arguments to check that the user holds a private key associated with one of the three on-chain public keys before allowing them to update the message:

@method async publishMessage(message: Field, signerPrivateKey: PrivateKey) {

Note that all inputs are private by default and exist only on the user's local machine when the smart contract runs. The Mina network never sees private inputs.

The smart contract sends only values that are stored as state to the Mina blockchain. This means that even though the value of the message argument is eventually public, the value of signerPrivateKey never leaves the user's machine as a result of interacting with the smart contract.

Compute signerPublicKey from signerPrivateKey

Now that you have the user's private key, you need to derive the associated public key to check it against the list of approved publishers. The PrivateKey type in o1js includes a toPublicKey() method:

// Compute signerPublicKey from signerPrivateKey argument
const signerPublicKey = signerPrivateKey.toPublicKey();

To check if this public key matches one of the keys stored on-chain:.

// Get approved public keys
const user1 = this.user1.get();
const user2 = this.user2.get();
const user3 = this.user3.get();

Calling the get() method retrieves these values from the zkApp account on-chain state.

note

o1js uses a single network request to retrieve all on-chain state values simultaneously.

Finally, check if signerPublicKey is equal to one of the allowed public keys contained in the user variables:

// Assert that signerPublicKey is one of the approved public keys
signerPublicKey
.equals(user1)
.or(signerPublicKey.equals(user2))
.or(signerPublicKey.equals(user3))
.assertEquals(true);

Notice the equals() and or() methods are used instead of the JavaScript operators (===, and ||). The built-in o1js methods have the same effect, but they work with o1js types and their execution can be verified using a zero knowledge proof.

assertEquals(true) at the end means that a valid proof is not generated unless signerPublicKey is equal to one of the pre-approved users. The Mina network rejects any transaction sent to a zkApp account that doesn't include a valid zero knowledge proof for that account. So it is impossible for users to post new messages unless they have a private key associated with one of the three pre-approved public keys.

Update message

Up to this point, the contract ensures that only approved users can call publishMessage(). When they do, the contract updates the on-chain message variable to their new message:

// Update on-chain message state
this.message.set(message);

The set() method asks the Mina nodes to update the value of their on-chain state, but only if the associated proof is valid.

Update messageHistoryHash

There's one more thing to do. If you want users to be able to keep track of what has been said, then you need to store a commitment to the message history on-chain.

There are a few ways to do this, but the simplest way is to store a hash of your new message and your old messageHistoryHash every time you call publishMessage:

// Compute new messageHistoryHash
const oldHash = this.messageHistoryHash.get();
const newHash = Poseidon.hash([message, oldHash]);

// Update on-chain state
this.messageHistoryHash.set(newHash);

That's it! Save the file. Now, to make sure everything compiles:

$ npm run build

If everything is correct, you see a new ./build directory where the compiled version of your project lives that you can import into a user interface.

Wrapping up

This tutorial gives you a sense of what's possible with o1js.

The messaging protocol you built is quite simple but also very powerful. You can use this basic pattern to create a whistleblower system, an anonymous NFT project, or even anonymous DAO voting.

The main point is that o1js makes it easy for you to build things that don't intuitively seem possible.

Zero knowledge proofs open the door to an entirely different way of thinking about the internet. We are so excited to see what people like you will build.

Make sure to join the #zkapps-developers channel on Mina Protocol Discord.

Keep going

The logical next steps to extend this project include:

  • Allow users to pass signers into the publishMessage() method directly so that many different organizations can use a single contract. Hint: You'll have to store a commitment to the signers on-chain.
  • Allow users to pass an arbitrarily large number of signers into the publishMessage() method.
  • Store the message history in a Merkle tree so a user interface can quickly check a subset of the messages without evaluating the entire history.
  • Build a shiny front end! import { showCompletionScript } from "yargs"