zkApp programmability is not yet available on the Mina Mainnet, but zkApps can now be deployed on Berkeley Testnet.
Anonymous Message Board Tutorial
How to write a zkApp provides a high-level overview of everything you need to know to build applications on Mina. This tutorial will put these ideas into practice as we walk through the design and implementation of a semi-anonymous messaging protocol.
Overview
We’ll build a smart contract that allows users to publish messages semi-anonymously. Our contract will allow a specific set of users to create new messages but will not disclose which user within the set has done so. This way, people can leverage one aspect of their identity without revealing exactly who they are. So, for example, a DAO member could make credible statements on behalf of their DAO without revealing their specific individual identity.
Setup
First, install the Mina zkApp CLI if you haven’t already done so.
Dependencies
You'll need the following installed to use the zkApp CLI:
- NodeJS 16+ (or 14 using
--experimental-wasm-threads
) - NPM 6+
- Git 2+
If you have an older version installed, we suggest installing a newer version using the package manager for your system: Homebrew (Mac), Chocolatey (Windows), or apt/yum/etc (Linux). On Linux, you may need to install a recent NodeJS version via NodeSource (deb or rpm), as recommended by the NodeJS Project.
Installation
$ npm install -g zkapp-cli
Usage
$ zk --help
Create a new project
$ zk project message-board # or path/to/message-board
✔ Fetch project template
✔ Initialize Git repo
✔ NPM install
✔ Set project name
✔ Git init commit
Success!
Next steps:
cd message-board
git remote add origin <your-repo-url>
git push -u origin main
This command creates a directory containing a new project template, fully set up & ready for local development.
- See the included README for usage instructions.
All usual commands will be available:
npm run build
,npm run test
,npm run coverage
, etc. - A Git repo will be initialized in the project directory automatically. For
consistency, we use
main
as the default Git branch, by convention. - A Github Actions CI workflow is
also included. If you push your project to Github, Github Actions will run
your tests (named as
*.test.js
) automatically, whenever you push a commit or open a pull request. - Code style consistency (via Prettier) and linting (via ES Lint) are
automatically enforced using Git pre-commit hooks. This requires no
configuration and occurs automatically when you commit to Git--e.g.
git commit -m 'feat: add awesome feature'
. - To skip all checks in the Git pre-commit hook (not recommended), you can pass
the
-n
flag to Git--e.g.git commit -m 'a bad commit' -n
. But we'd recommend avoiding this and resolving any errors which exist in your project until the pre-commit hook passes.
Scaffolding
Now that your project is set up, you can open it in your IDE or cd message-board
if you work from the command line.
There should be an example smart contract in ./src
called Add.ts
and tests for it in Add.test.ts
. These are just example code, and you can delete them if they bother you.
Let’s create a new contract by running:
$ zk file message
Now open it (message.ts
) up and paste in the following:
import {
Field,
SmartContract,
state,
State,
method,
DeployArgs,
Permissions,
PrivateKey,
PublicKey,
isReady,
Poseidon,
Encoding,
} from 'o1js';
export { isReady, Field, Encoding };
// Wait till our o1js instance is ready
await isReady;
// 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 Add extends SmartContract {
// On-chain state definitions
@method init() {
// Define initial values of on-chain state
}
@method publishMessage(message: Field, signerPrivateKey: PrivateKey) {
// Compute signerPublicKey from signerPrivateKey argument
// Get approved public keys
// Assert that signerPublicKey is one of the approved public keys
// Update on-chain message variable
// Computer new messageHistoryHash
// Update on-chain messageHistoryHash
}
}
This will serve as the scaffolding for the rest of the tutorial and contains a smart contract called Message
with two methods: init()
and publishMessage()
. The init()
method is similar to the constructor
in Solidity. It’s a place for you to define any setup that needs to happen before users begin interacting with the contract. publishMessage()
is the method that users will invoke when they want to create a new message. The @method
decorator tells o1js that users should be allowed to call this method and that it should generate a zero knowledge proof of its execution.
Writing the Smart Contract
Defining 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 can store commitments (i.e. the hash of a file, the root of a Merkle tree, etc.) to off-chain storage, but in this case, we’ll store everything on-chain for the sake of simplicity.
General purpose off-chain storage libraries are planned, but you can always roll your own solution if desired.
In this smart contract, one state variable will store the last message. Another will store the hash of all the previous messages (so a frontend can validate message history), and three more will store user public keys (we could store additional public keys by Merkelizing them, but we’ll keep it to three here for the sake of brevity).
export class Add 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 should be 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. You can learn a little more about finite fields here, but it’s not necessary to understand exactly how field arithmetic works for this tutorial. o1js also provides UInt32
, UInt64
, and Int64
types, but under the hood, all o1js types are composed of the Field type (including PublicKey
, as you see above).
Defining the init()
method
The init
method is similar to the constructor
in Solidity. It’s a place for you to define any setup that needs to happen before users begin interacting with the contract. In this case, we’ll set the public keys of users who can post, and initialize message
and messageHistoryHash
as zero (our front end will interpret the zero value to mean that no messages have been posted yet).
@method 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));
}
Defining publishMessage()
The publishMessage
method will allow an approved user to publish a message. Note the @method
decorator mentioned earlier. It makes this method callable by users so that they can interact with the smart contract.
For our example, we’ll 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 publishMessage(message: Field, signerPrivateKey: PrivateKey) {
// Compute signerPublicKey from signerPrivateKey argument
// Get approved public keys
// Assert that signerPublicKey is one of the approved public keys
// Update on-chain message state
// Computer new messageHistoryHash
// Update on-chain messageHistoryHash
}
Note that all inputs are private by default and will only exist on the user’s local machine when the smart contract runs; the Mina network will never see them. Our smart contract will only send values that are stored as state to the Mina blockchain. This means that even though the value of the message
argument will eventually be public, the value of signerPrivateKey
will never leave the user's machine (as a result of interacting with the smart contract).
Computing signerPublicKey
from signerPrivateKey
Now that we have the user’s private key, we’ll need to derive the associated public key to check it against the list of approved publishers. Luckily the PrivateKey
type in o1js includes a toPublicKey()
method.
// Compute signerPublicKey from signerPrivateKey argument
const signerPublicKey = signerPrivateKey.toPublicKey();
We’ll have to check if this public key matches one of the ones stored on-chain. So let’s grab those as well.
// Get approved public keys
const user1 = this.user1.get();
const user2 = this.user2.get();
const user3 = this.user3.get();
Calling the get()
method tells o1js to retrieve these values from the zkApp account’s on-chain state. (Note that o1js uses a single network request to retrieve all on-chain state values simultaneously.)
Finally, we check if signerPublicKey
is equal to one of the allowed public keys contained in our 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 that we call the o1js equals()
and or()
methods instead of using 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 it will be impossible to generate a valid proof unless signerPublicKey
is equal to one of the pre-approved users. The Mina network will reject any transaction sent to a zkApp account that doesn’t include a valid zero knowledge proof for that account. So it will be impossible for users to post new messages unless they have a private key associated with one of the three pre-approved public keys.
Updating message
Until now, we have worked to ensure that only approved users can call publishMessage()
. When they do, the contract should update the on-chain message
variable to their new message.
// Update on-chain message state
this.message.set(message);
The set()
method will ask the Mina nodes to update the value of their on-chain state, but only if the associated proof is valid.
Updating messageHistoryHash
There’s one more thing we should do. If we want users to be able to keep track of what has been said, then we need to store a commitment to the message history on-chain. There are a few ways to do this, but the simplest is to store a hash of our new message
and our old messageHistoryHash
every time we 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, and let’s make sure everything compiles.
$ npm run build
If everything is correct, you should see a new ./build
directory. This is where the compiled version of your project lives that you can import into a user interface.
Integrating with a User Interface (Coming Soon)
Wrapping up
Hopefully you enjoyed this tutorial, and it gave you a sense of what's possible with o1js. The messaging protocol we built is quite simple but also very powerful. This basic pattern could be used to create a whistleblower system, an anonymous NFT project, or even anonymous DAO voting. o1js makes it easy for developers to build things that don’t intuitively seem possible, and this is really the point.
Zero knowledge proofs open the door to an entirely different way of thinking about the internet, and we are so excited to see what people like you will build.
- Make sure to join the #zkapps-developers channel on Mina Protocol Discord.
- If you are interested in building something more complex with the guidance of our team at O(1) Labs, you can watch for future zkApp Builders Programs.
- To suggest tutorials, submit your ideas in the o1js/zkApp Tutorial Request Form.
Here are the logical next steps to extend this project.
- 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!