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
Create or change to a directory where you have write privileges.
Create the project:
$ zk project
The
zk project
command has the ability to scaffold the UI for your project. For this tutorial, selectnone
:? Create an accompanying UI project too? …
next
svelte
nuxt
empty
❯ noneThe
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, themain
branch is the default branch.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 thesrc
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.
Optional: Delete the example files not used in this tutorial:
$ rm src/Add.ts
$ rm src/Add.test.tsTo create the
src/message.ts
file:$ zk file message
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 theconstructor
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.
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.
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"