In this article, we will explore Mina's tools and how they work together, allowing us to understand Mina and its underlying technology.
Introduction
Mina's lightweight blockchain technology provides collaborative, reusable proof of everything, which enables an internet of true things where data is verifiable, accessible, and privacy-preserving, promoting a more open and trustworthy digital world.
Mina empowers developers to build innovative digital products. Tools like 01labs and Protokit provide the necessary infrastructure, along with the zero-knowledge tech stack, which includes the Kimchi-Pickles proof system, Ouroboros Samasika POS Consensus, zkApps, Snarketplace Proof market and Snarkworkers facilitating the creation of the next generation of applications.
We begin by taking a look at the zero-knowledge tech stack
Kimchi-Pickles proof system
Proof systems are built specifically to prove statements, Mina's unique proof system is known for being highly efficient and composable, Pickles is Mina's proof system that creates recursive proofs which rely on a protocol called Kimchi to produce the proofs, this allows the Mina blockchain to remain of a fixed size of about 22kb. Mina Protocol is the only blockchain that offers infinite recursion.
Kimchi is based on PLONK, a proof system released in 2019, Since then, many improvements and extensions have been proposed. There’s been fflonk, turbo PLONK, ultra PLONK, plonkup, and recently plonky2. One common theme is all these protocols implement variants of PLONK.
Imagine Alice wants to verify Bob solved a puzzle without revealing his solution. Traditional methods require Bob to share the solution, which isn't ideal. This is where protocols like PLONK shine
- A program verifying the solution is transformed into two pieces: a prover and verifier "index", not actual keys.
- Bob uses the prover index, puzzle, and his solution to create a proof without revealing the solution.
- Alice verifies the proof using the verifier index. If successful, she knows Bob solved the puzzle without seeing his solution.
This enables secure verification while keeping private information confidential.
Ouroboros Samasika POS Consensus
Mina employs Ouroboros Samasika, a provably secure Proof-of-Stake (PoS) protocol. Built upon the Ouroboros family based on Cardano’s Ouroboros, Samasika enhances consensus by enabling efficient block production and eliminating the need for extensive historical checks. This ensures high security while mitigating centralization risks by resolving long-range forks without relying on trusted third parties or requiring the storage of extensive blockchain history.
Like all proof of stake protocols, it requires less computing power than Bitcoin's proof of work. "Imagine Proof-of-Stake as a dart game. Your stake size determines your portion of the 'dartboard.' The larger your stake, the greater your chance of 'hitting the target' and being selected to produce the next block.
Because people’s hold on stake changes frequently, the distribution of stake on the dartboard must change as well.
In the dartboard example, the dartboard must be redrawn to recalculate the stake distribution for every epoch. Each epoch in Mina consists of 7,140 slots, and in each slot, a new dart is thrown (aka a new block is produced). As of the mainnet launch, each slot in Mina lasts 3 minutes, meaning each epoch is around 14 days. There is no minimum staking requirement with Mina. Anyone can stake their own MINA or delegate their stake to another person.
Ouroboros uses Verifiable Random Functions (VRFs) to determine block producers in a secure and decentralized manner. VRFs allow block producers to secretly determine their turn to produce a block, preventing targeted attacks.
Using random selection block production is probabilistic, based on the size of a stakeholder's stake. Multiple block producers can be chosen for a slot, enhancing security and minimizing the impact of potential attacks, In the case of multiple valid blocks for a slot, the longest chain is selected, ensuring consensus.
zkApps
zkApps leverage zero-knowledge proofs (zk-SNARKs) for secure and private computations. Developers use the zkApp CLI and o1js to build these applications. The core element is the prover function, written in o1js, which executes the zkApp's logic. This function, running in the user's browser, generates a zero-knowledge proof demonstrating the correct execution of the logic without revealing sensitive data.
For example, in a decentralized exchange, a user might use the zkApp to trade assets privately, with the proof verifying the trade's validity without disclosing trade details.
- Inputs: Prover functions require both private and public inputs. Private inputs remain confidential, while public inputs are shared for verification.
- Verification: The verifier function, efficiently executed by the Mina network, validates the proof against the defined constraints.
- Efficiency: Regardless of prover function complexity, verification remains swift and efficient.
To use a zkApp, end users must Install a Wallet that supports interactions with zkApps.
After a zkApp is deployed to a host, end users can interact with it:
The user visits funzkapp.com.
The user interacts with the zkApp and enters the required data. For example, if this were an automated market maker, the user might specify to buy x amount of ABC at y price.
The prover function in the zkApp generates a zero-knowledge proof locally using the data entered by the user.
This data can be either:
Private, the data is never seen by the blockchain.
Public, the data is stored on-chain or off-chain, depending on what the zkApp specified as required for a given use case.
A list of state updates called account updates, which will be created by the transaction is generated. The account updates are associated with this proof.
- The user selects Submit to chain in the zkApp UI.
The user confirms the transaction on their wallet.
The wallet signs the transaction containing the proof and the associated description of the state to update.
The wallet sends the transaction to the Mina network.
- The Mina network receives this transaction and verifies that the proof successfully passes the verifier method listed on the zkApp account. If the network accepts this transaction, this proof and the requested state changes are valid and are allowed to update the zkApp state.
The end user's privacy is maintained because their interaction occurs locally in a web browser using JavaScript on the client. The zkApp account gets updated on-chain. When the prover function runs in a web browser, the smart contract outputs proof and some associated data called "account updates" that are sent to a zkApp address as part of the transaction.
The integrity of these account updates is ensured by passing a hash of the account updates as public input to the smart contract. The account updates must be present and unmodified for the verification function to pass successfully when it runs on Mina. This allows the Mina network to confirm the integrity of both the proof and the associated account updates
There are two zkApp state
On-chain state describes the state that lives on the Mina blockchain. Each zkApp account provides 8 fields of 32 bytes each of arbitrary storage. You may store anything here as long as it fits in the size provided. If the state is to be larger, or if the state accumulates per user within your zkApp, then the best option is to use off-chain state
Off-chain state describes the state stored anywhere else. For larger data, you might want to consider storing the root of a Merkle tree or a similar data structure within your zkApp's on-chain storage that references self-hosted off-chain state stored elsewhere. Mina doesn't offer an out-of-the-box solution for off-chain storage
When the zkApp runs in a user's web browser, it can insert state to an external storage, such as IPFS. When the transaction is sent to the Mina network, if it accepts this zkApp transaction then proof and state are known to be valid so the updates are allowed, then the zkApp transaction can update the root of the Merkle tree that is stored onchain.
With this knowledge, we will explore zkApps further by building one after we get an introduction to o1js and its basic concepts.
Snarketplace Proof market and Snark Workers
The Snarketplace is a service exchange where block producers add transactions for SNARK Workers to create ZKPs and process them. This marketplace model ensures the network’s compact size and efficient transaction processing by requiring completed SNARK work to be purchased before adding new block producer transactions.
While most protocols have just one primary group of node operators (often called miners, validators, or block producers), Mina has a second group — the SNARK worker.
SNARK workers are integral to Mina's operation. They produce the zero-knowledge proofs (SNARKs) that verify the validity of each block. These proofs are essential for maintaining Mina's succinctness, allowing nodes to discard historical data and retain only the latest SNARK, significantly reducing storage requirements.
The reason for this is each block producer, when they propose a new block to the network, must also include a zk-SNARK along with that block. This allows nodes to discard all historical data that's been finalized, and retain just the SNARK.
While block producers generate SNARK proofs for blocks, individual transactions within those blocks also require to be SNARKED. This is crucial because the blockchain-level SNARK only verifies the integrity of the block itself, not the validity of the transactions included within it.
For example: Imagine the current blockchain state is represented by hash 'a6f8792226...'. A new block arrives with a state hash '0ffdcf284f...' and an accompanying SNARK. This SNARK proves that a block exists which extends the blockchain with the previous state hash 'a6f8792226...'. However, this SNARK doesn't guarantee the validity of the transactions within that new block. A malicious block producer could include invalid transactions without being detected by this blockchain-level SNARK.
To address this, each transaction within a block also requires its own individual SNARK, ensuring the validity of all included transactions.
To ensure that nodes can operate without trust on the Mina blockchain, it is important that each node can verify the state of the chain without needing to replay the transactions. For this to work, the blockchain SNARK is not enough. We need to know that the transactions are also valid.
Individually generating SNARKs for each transaction is inefficient. This leads to slow block times due to the high computational cost and difficulties in handling asynchronous transaction arrivals. Lucky for us, we can leverage two properties about SNARKs:
- proofs can be merged - two proofs can be combined to form a merge proof
- merges are associative - merge proofs are identical, regardless of the order merged
What these two properties essentially allow us to do is take advantage of parallelism. If proofs can be merged, and it doesn't matter how they're combined, then SNARK proofs can be generated in parallel. Whichever proof is finished first can be combined later with the proofs in progress.
This can be envisioned as a binary tree, where the bottom row (the leaves) consists of the individual transaction proofs, and each parent row is the set of respective merge proofs. We can combine these to the root, which represents a state update performed by applying all the transactions.
Next, let's focus on the practical implementation. We'll discuss how to utilize 01js and Protokit to develop zkApps on the Mina platform.
o1js
o1js is a TypeScript library for writing general-purpose zero knowledge (zk) programs and writing zk smart contracts for Mina. As with any library for building projects, we need to have an understanding of the fundamental concepts, this helps us know the appropriate steps to take when we want to create our applications.
Basic concepts
- Field Field elements are the basic unit of data in zero-knowledge-proof programming. Each field element can store a number up to almost 256 bits in size. You can think of a field element as a uint256 in Solidity.
For Example
// For example, in typical programming, you might use:
const sum = 1 + 3.
// In o1js, you write this as:
const sum = new Field(1).add(new Field(3))
- Built-in data types
Some common data types found in 01js
new Bool(x); // accepts true or false
new Field(x); // accepts an integer, or a numeric string if you want to represent a number greater than JavaScript can represent
but within the max value that a field can store.
new UInt64(x); // accepts a Field - useful for constraining numbers to 64 bits
new UInt32(x); // accepts a Field - useful for constraining numbers to 32 bits
PrivateKey, PublicKey, Signature; // useful for accounts and signing
new Group(x, y); // a point on our elliptic curve, accepts two Fields/numbers/strings
Scalar; // the corresponding scalar field (different than Field)
CircuitString.from('some string'); // string of max length 128
In the case of Field and Bool, you can also call the constructor without new:
let x = Field(10);
let b = Bool(true);
- Conditionals
Unlike conditional statements common in programming, traditional conditional statements are not supported by o1js
// this will NOT work
if (foo) {
x.assertEquals(y);
}
Instead, use the o1js built-in Circuit.if()
method, which is a ternary operator:
const x = Circuit.if(new Bool(foo), a, b); // behaves like `foo ? a : b`
- Functions
Functions work as you would expect in TypeScript.
function addOneAndDouble(x: Field): Field {
return x.add(1).mul(2);
}
- Common methods
Some frequently used common methods are
let x = new Field(4); // x = 4
x = x.add(3); // x = 7
x = x.sub(1); // x = 6
x = x.mul(3); // x = 18
x = x.div(2); // x = 9
x = x.square(); // x = 81
x = x.sqrt(); // x = -9
let b = x.equals(8); // b = Bool(false)
b = x.greaterThan(8); // b = Bool(true)
b = b.not().or(b).and(b); // b = Bool(true)
b.toBoolean(); // true
let hash = Poseidon.hash([x]); // takes array of Fields, returns Field
let privKey = PrivateKey.random(); // create a private key
let pubKey = PublicKey.fromPrivateKey(privKey); // derive public key
let msg = [hash];
let sig = Signature.create(privKey, msg); // sign a message
sig.verify(pubKey, msg); // Bool(true)
For a full list, see the o1js reference here.
Recursion
As mentioned above Kimchi, the custom proof system that backs o1js, supports arbitrary infinite recursive proof construction of circuits through integration with the Pickles recursive system.
Recursion is an incredibly powerful primitive that has a wide array of uses. Generally, you can use recursion to verify any zero knowledge program as part of your zkApp. Some examples are:
Mina uses linear recursive proofs to compress the blockchain, an infinitely growing structure, down to a constant size.
Mina also uses "rollup-like" tree-based recursive proofs to, parallel, compress transactions within blocks down to a constant size.
An app-specific rollup like a Mastermind game that uses linear recursive proofs to progress the state machine of the application without needing to sync back to the game.
App-specific rollups can use recursion to communicate with each other, like app chains using Inter-Blockchain Communication protocol or parachains using Cross-Chain Virtual Machines to send messages.
In o1js, you can use ZkProgram() to define the steps of a recursive program. Like SmartContract() methods, ZkProgram() methods execute off-chain.
After performing the desired recursive steps, you can settle the interaction on Mina's blockchain by embedding ZkProgram within a SmartContract method that verifies the underlying proof of execution and extracts the output that can be used elsewhere in the method like storing the output in app-state.
This simple example has only one method that proves the public input it received is zero:
import { Field, ZkProgram } from 'o1js';
const SimpleProgram = ZkProgram({
name: 'simple-program-example',
publicInput: Field,
methods: {
run: {
privateInputs: [],
async method(publicInput: Field) {
publicInput.assertEquals(Field(0));
},
},
},
});
To compile this program
const { verificationKey } = await SimpleProgram.compile();
Now, you can use it to create a proof
const { proof } = await SimpleProgram.run(Field(0));
To verify this proof from within any method of your SmartContract class:
@method async foo(proof: SimpleProgram.Proof) {
proof.verify().assertTrue();
const output: Field = proof.value;
// ...the rest of our method.
// For example, storing the output of the execution of the program we've
// proven as on-chain state, if desired.
}
In this example, foo is taking the SimpleProgram proof as a private argument to the method, verifying that the execution was valid, and then using the output.
You can find more examples and use cases here
It is time for us to see o1js in action
We are going to start with some prerequisites, it is mandatory to have zkApp CLI installed and this can be done with this command
npm install -g zkapp-cli
In our terminal, we'll create a new project with this command
zk project 01-hello-world
The zk project command has the ability to scaffold the UI for your project. For this tutorial, select none:
Create an accompanying UI project too? …
next
svelte
nuxt
empty
> none
Our expected output is going to be:
✔ Create an accompanying UI project too? · none
✔ UI: Set up project
✔ Initialize Git repo
✔ Set up project
✔ NPM install
✔ NPM build contract
✔ Set project name
✔ Git init commit
Success!
Next steps:
cd 01-hello-world
git remote add origin <your-repo-url>
git push -u origin main
The zk project command creates a 01-hello-world directory that contains the scaffolding for your project, including tools such as the Prettier code formatting tool, the ESLint static code analysis tool, and the Jest JavaScript testing framework.
We can move into the 01-hello-world
with its command
cd 01-hello-world
// we can list the items in the directory with
ls
Before we begin, we need to do a clean-up by deleting some old files with these commands
rm src/Add.ts
rm src/Add.test.ts
rm src/interact.ts
Then we can create new files with these commands
zk file src/Square
touch src/main.ts
The zk file
command creates the src/Square.ts and src/Square.test.ts test files. Now we open src/index.ts
in a text editor and change it to look like
import { Square } from './Square.js';
export { Square };
The src/index.ts file contains all of the exports we want to make available for consumption from outside our smart contract project, such as from a user interface.
Now we have this set up let's write the smart contract, in the src/Square.ts
file, we import
import {
Field,
SmartContract,
state,
State,
method,
} from 'o1js';
Each item in the import are
Field: The native number type in o1js.
SmartContract: The class that creates zkApp smart contracts.
state: A convenience decorator used in zkApp smart contracts to create references to state stored on-chain in a zkApp account.
State: A class used in zkApp smart contracts to create a state stored on-chain in a zkApp account.
method: A convenience decorator used in zkApp smart contracts to create smart contract methods like functions. Methods that use this decorator are the end user's entry points to interacting with a smart contract.
In the same file, we add the smart contract called Square has one element of on-chain state named num of type Field as defined by the following code
export class Square extends SmartContract {
@state(Field) num = State<Field>();
}
As mentioned earlier zkApps can have up to eight fields of on-chain state. We add init
method to the code to set up the initial state of the smart contract on deployment
init() {
super.init();
this.num.set(Field(3));
}
Since this code extends SmartContract
it has its own initialization to perform, calling super.init() invokes this function on the base class. Then, this.num.set(Field(3))
initializes the on-chain state num to a value of 3.
Finally, we add an update() function
@method async update(square: Field) {
const currentState = this.num.get();
this.num.requireEquals(currentState);
square.assertEquals(currentState.mul(currentState));
this.num.set(square);
}
When put together, it should look like this
import { Field, SmartContract, state, State, method } from 'o1js';
export class Square extends SmartContract {
@state(Field) num = State<Field>();
init() {
super.init();
this.num.set(Field(3));
}
@method async update(square: Field) {
const currentState = this.num.get();
this.num.requireEquals(currentState);
square.assertEquals(currentState.mul(currentState));
this.num.set(square);
}
}
The function name update is arbitrary, but it makes sense for this example. Notice how the @method decorator is used because it is intended to be invoked by end users by using a zkApp UI, or as in this case, the main.ts script.
This method contains the logic by which end users are allowed to update the zkApp's account state on chain. A zkApp account is an account on the Mina blockchain where a zkApp smart contract is deployed. A zkApp account has a verification key associated with it.
In our example:
If the user provides a number for example, 9 to the update() method that is the square of the existing on-chain state referred to as num for example, 3, then update the num value that is stored on-chain to the provided value in this case, 9.
If the user provides a number that does not meet these conditions, they are unable to generate proof or update the on-chain state.
These update conditions are accomplished by using assertions within the method. When a user invokes a method on a smart contract, all assertions must be true to generate the zero knowledge proof from that smart contract. The Mina network accepts the transaction and updates the on-chain state only if the attached proof is valid. This assertion is how you can achieve predictable behaviour in an off-chain execution model.
Notice that get() and set() methods are used for retrieving and setting on-chain state.
A smart contract retrieves the on-chain account state when it is first invoked if at least one get() exists within it.
Similarly, using set() changes the transaction to indicate that changes to this particular on-chain state are updated only when the transaction is received by the Mina network if it contains a valid authorization (usually, a valid authorization is proof).
For the next step, we need to interact with a smart contract, we will write a script that interacts with your smart contract. As before, the complete main.ts example file is provided. In the main.ts
file we are going to do the following
import { Square } from './Square.js';
import { Field, Mina, PrivateKey, AccountUpdate } from 'o1js';
Using a simulated local blockchain speeds up development and tests the behaviour of your smart contract locally, To initialize the simulated local blockchain, we add the following code from the main.ts file
const useProof = false;
const Local = await Mina.LocalBlockchain({ proofsEnabled: useProof });
Mina.setActiveInstance(Local);
const deployerAccount = Local.testAccounts[0];
const deployerKey = deployerAccount.key;
const senderAccount = Local.testAccounts[1];
const senderKey = senderAccount.key;
This simulated local blockchain provides pre-funded accounts. So we add these lines to create local test accounts with test MINA (tMINA) four our own use.
// ----------------------------------------------------
// Create a public/private key pair. The public key is your address and where you deploy the zkApp to
const zkAppPrivateKey = PrivateKey.random();
const zkAppAddress = zkAppPrivateKey.toPublicKey();
After we have made this changes our main.ts
file should look like this
import { Square } from './Square.js';
import { Field, Mina, PrivateKey, AccountUpdate } from 'o1js';
const useProof = false;
const Local = await Mina.LocalBlockchain({ proofsEnabled: useProof });
Mina.setActiveInstance(Local);
const deployerAccount = Local.testAccounts[0];
const deployerKey = deployerAccount.key;
const senderAccount = Local.testAccounts[1];
const senderKey = senderAccount.key;
// ----------------------------------------------------
// Create a public/private key pair. The public key is your address and where you deploy the zkApp to
const zkAppPrivateKey = PrivateKey.random();
const zkAppAddress = zkAppPrivateKey.toPublicKey();
We can now build and run the smart contract, we have to first build to compile the TypeScript code into JavaScript with this command:
npm run build
Then we run the JavaScript code with
node build/src/main.js
We can combine to make it run in one command with
npm run build && node build/src/main.js
We have set up our environment to let us run the smart contract, we now have to move to initialize our smart contract, we are going to add more code to main.ts
.
// create an instance of Square - and deploy it to zkAppAddress
const zkAppInstance = new Square(zkAppAddress);
const deployTxn = await Mina.transaction(deployerAccount, async () => {
AccountUpdate.fundNewAccount(deployerAccount);
await zkAppInstance.deploy();
});
await deployTxn.sign([deployerKey, zkAppPrivateKey]).send();
// get the initial state of Square after deployment
const num0 = zkAppInstance.num.get();
console.log('state after init:', num0.toString());
In the above what we are doing is:
We create a public/private key pair; the public key is an address on the Mina network where you deploy the zkApp to
We create an instance of your smart contract Square and deploy it to zkAppAddress
Then we get the initial state of Square after deployment
Then we run the below in the terminal
npm run build && node build/src/main.js
our expected output is
state after init: 3
The next thing we want to do is update zkApp account with a transaction
// ----------------------------------------------------
const txn1 = await Mina.transaction(senderAccount, async () => {
await zkAppInstance.update(Field(9));
});
await txn1.prove();
await txn1.sign([senderKey]).send();
const num1 = zkAppInstance.num.get();
console.log('state after txn1:', num1.toString());
then we run our command again and our expected output should be
state after init: 3
state after txn1: 9
One more thing we have to explore is when a transaction fails, this gives us the full breadth to know what we are doing, the contract logic allows the number that is stored as on-chain state to be replaced only by its square. Now that num is in state 9, updating is possible only with 81. To test a failure, we add these next lines of code change the state to 75 in zkAppInstance.update(Field(75))
// ----------------------------------------------------
try {
const txn2 = await Mina.transaction(senderAccount, async () => {
await zkAppInstance.update(Field(75));
});
await txn2.prove();
await txn2.sign([senderKey]).send();
} catch (error: any) {
console.log(error.message);
}
const num2 = zkAppInstance.num.get();
console.log('state after txn2:', num2.toString());
Like we have done previously to test we run our command and the expected output should be
state after init: 3
state after txn1: 9
Field.assertEquals(): 75 != 81
state after txn2: 9
And finally, be sure to change your main.ts
file to include the correct update to change the state to 81 in zkAppInstance.update(Field(81)). After running the command again the output should be
state after init: 3
state after txn1: 9
state after txn2: 81
We have been able to play around with o1js to see what it offers and how it powers Mina application.
We have one more topic to touch which is Protokit and what it offers us in Mina.
Protokit
Protokit is a framework for building privacy-enabled application chains a.k.a. zk-roll-apps/zk-rollups. Enabling developers to build zero-knowledge privacy-preserving applications with a minimal learning curve. The framework itself is powered by o1js, an SDK for building zkApps. All applications built with Protokit are compatible with the Mina blockchain by design and can use the Mina L1 for settlement.
Rollups
Rollups are a form of an L2 scaling solution, originating from networks such as Ethereum. L2 solutions aim to address the scalability issues of L1 networks by moving transactions off-chain, while still maintaining the security guarantees of the L1 network.
Rollups alleviate the potential race condition scenarios by moving the execution from the client side to the server/sequencer side. Protokit offers a hybrid execution model, where code can run both on and off-chain, thanks to recursive zero-knowledge proofs. This allows retaining the capabilities of smart contracts while extending them with the capabilities of sequential execution.
Application Chain
The application chain is an application-specific form of a rollup. Application-specific rollups allow developers to optimize their rollup implementation to suit their application's needs, providing a better user experience like faster block times and potentially lower fees than general-purpose rollups like zkSync.
Protokit Architecture consists of Runtime, Protocol and Sequencer, Let's break this down further.
- Runtime
Runtime is the first modular component of the app chain, which contains all the business logic of the application itself. Business logic is implemented as runtime modules, defining the runtime state and runtime methods of each module.
Runtime methods are functions callable by user's transactions, responsible for reading and writing to the state following their inner business logic implementation. Each runtime module can define its own state (storage), which can be of two types: state and state map.
- Protocol
Protocol is the focal point of every Protokit app chain.
Firstly it defines how state transitions generated by the rest of the framework are applied to the underlying state tree.
Secondly, it determines how proofs of state transitions are brought together with proofs of runtime execution to form a block, or at least a part of it.
Every protocol is composed of protocol modules, following the modular design of Protokit. The bare minimum protocol must always contain a StateTransitionProver and a BlockProver module.
- Sequencer
Sequencer is the gateway between the user's transactions and the block production. the sequencer is responsible for accepting user's transactions into a mempool, validating them, sequencing them as part of the block production process, orchestrating the block production prover worker pipeline, and finally submitting the rollup block to the settlement layer.
All working components of the sequencer are sequencer modules and may be added, removed or replaced at the developer's convenience following the modularity principles.
With this overview, we can now get our hands dirty building a single runtime module. The runtime module would store a ledger of balances for public keys, and the current circulating supply.
We are going to use the starter kit here, navigate to packages/chain/src/balances.ts
and you'll see the snippet below, one thing you'll also notice is we're making use of o1js.
import { runtimeModule, state, runtimeMethod } from "@proto-kit/module";
import { State, assert } from "@proto-kit/protocol";
import { Balance, Balances as BaseBalances, TokenId } from "@proto-kit/library";
import { PublicKey } from "o1js";
interface BalancesConfig {
totalSupply: Balance;
}
@runtimeModule()
export class Balances extends BaseBalances<BalancesConfig> {
@state() public circulatingSupply = State.from(Balance);
// implicitly inherited from `BaseBalances`
// @state() public balances = StateMap.from(..);
@runtimeMethod()
public addBalance(tokenId: TokenId, address: PublicKey, amount: Balance) {
const circulatingSupply = this.circulatingSupply.get();
const newCirculatingSupply = Balance.from(circulatingSupply.value).add(
amount
);
assert(
newCirculatingSupply.lessThanOrEqual(this.config.totalSupply),
"Circulating supply would be higher than total supply"
);
this.circulatingSupply.set(newCirculatingSupply);
this.mint(tokenId, address, amount);
}
}
The first thing we'll be doing is testing the runtime, it is a good practice to test your runtime modules in isolation.
In the packages/chain/test/runtime/modules
folder we'll navigate to our test file named balances.test.ts
where we'll do the following
We'll Setup a
TestingAppChain.fromRuntime
, providing only the Balances module.We Provide non-mutable configuration for the Balances module, including the totalSupply.
Then we start the application chain, which generates keypairs for testing and sets the transaction signer.
It'll forge a transaction to the Balances module, calling the addBalance method.
Then it signs and sends the transaction and produces a block for the application chain.
After that it Queries the Balances module for the balance of the test account from keypair generated earlier.
It asserts that the transaction was included in the block, while the execution of the transaction was successful.
It also asserts that the balance of the test account has been updated accordingly.
import { TestingAppChain } from "@proto-kit/sdk";
import { PrivateKey } from "o1js";
import { Balances } from "../src/balances";
import { log } from "@proto-kit/common";
import { BalancesKey, TokenId, UInt64 } from "@proto-kit/library";
log.setLevel("ERROR");
describe("balances", () => {
it("should demonstrate how balances work", async () => {
const appChain = TestingAppChain.fromRuntime({
Balances,
});
appChain.configurePartial({
Runtime: {
Balances: {
totalSupply: UInt64.from(10000),
},
},
});
await appChain.start();
const alicePrivateKey = PrivateKey.random();
const alice = alicePrivateKey.toPublicKey();
const tokenId = TokenId.from(0);
appChain.setSigner(alicePrivateKey);
const balances = appChain.runtime.resolve("Balances");
const tx1 = await appChain.transaction(alice, () => {
balances.addBalance(tokenId, alice, UInt64.from(1000));
});
await tx1.sign();
await tx1.send();
const block = await appChain.produceBlock();
const key = new BalancesKey({ tokenId, address: alice });
const balance = await appChain.query.runtime.Balances.balances.get(key);
expect(block?.transactions[0].status.toBoolean()).toBe(true);
expect(balance?.toBigInt()).toBe(1000n);
}, 1_000_000);
});
Then we run the test command with the following
pnpm run test --filter=chain --watchAll
After that is successful, we can go on to configure the app-chain, which can be configured at three different levels, the runtime, the chain and the client. We are going to start the runtime configuration, specifying the configuration of the runtime module layout.
In the packages/chain/src/runtime/index.ts
we have
import { Balance } from "@proto-kit/library";
import { Balances } from "./balances";
import { ModulesConfig } from "@proto-kit/common";
export const modules = {
Balances,
};
export const config: ModulesConfig<typeof modules> = {
Balances: {
totalSupply: Balance.from(10_000),
},
};
export default {
modules,
config,
};
The runtime configuration above is then used to define app-chain configurations for both client and server side app-chains. One thing to keep in mind is that configuration for the rest of the app chain, namely the protocol and the sequencer is provided implicitly behind the scenes.
So our packages/chain/src/chain.config.ts
and packages/chain/src/client.config.ts
will look like the below respectively
import { LocalhostAppChain } from "@proto-kit/cli";
import runtime from "./runtime";
const appChain = LocalhostAppChain.fromRuntime(runtime.modules);
appChain.configure({
...appChain.config,
Runtime: runtime.config,
});
// TODO: remove temporary `as any` once `error TS2742` is resolved
export default appChain as any;
import { ClientAppChain } from "@proto-kit/sdk";
import runtime from "./runtime";
const appChain = ClientAppChain.fromRuntime(runtime.modules);
appChain.configurePartial({
Runtime: runtime.config,
});
export const client = appChain;
This helps us with the server & client configuration. With this, we can implement our own runtime module. Here we have to consider a couple of things:
- What will be configurable in the module?
- What data will the module store?
- What methods will the module expose?
In packages/chain/src/
we will create a folder named guest-book
and in there we'll have our check-in.ts
import { Field, PublicKey, Struct } from "o1js";
import { UInt64 } from "@proto-kit/library";
export class CheckInId extends Field {}
export class CheckIn extends Struct({
guest: PublicKey,
createdAt: UInt64,
rating: UInt64,
}) {}
we'll want to allow users to check in in the guest book. We'll start by defining the data model, namely the CheckIn struct, which will determine what constitutes a check-in. Then we'll take the next step creating our runtime module and defining the checkIns storage property, which will map a guest to the check-in they made.
import {
RuntimeModule,
runtimeMethod,
runtimeModule,
state,
} from "@proto-kit/module";
import { StateMap, assert } from "@proto-kit/protocol";
import { PublicKey } from "o1js";
import { CheckIn } from "./check-in";
@runtimeModule()
export class GuestBook extends RuntimeModule<Record<string, never>> {
@state() public checkIns = StateMap.from(PublicKey, CheckIn);
}
Now that we have our storage defined, we can start implementing the methods that will allow users to interact with the module. We'll define the checkIn_ method, which will allow users to check-in. Inside the guest-book
folder we'll create an index.ts
file where we have the following:
import {
RuntimeModule,
runtimeMethod,
runtimeModule,
state,
} from "@proto-kit/module";
import { StateMap, assert } from "@proto-kit/protocol";
import { PublicKey } from "o1js";
import { CheckIn } from "./check-in";
import { UInt64 } from "@proto-kit/library";
@runtimeModule()
export class GuestBook extends RuntimeModule<Record<string, never>> {
@state() public checkIns = StateMap.from(PublicKey, CheckIn);
@runtimeMethod()
public checkIn(rating: UInt64) {
assert(rating.lessThanOrEqual(UInt64.from(5)), "Maximum rating can be 5");
const guest = this.transaction.sender.value;
const createdAt = UInt64.from(this.network.block.height);
const checkIn = new CheckIn({
guest,
createdAt,
rating,
});
this.checkIns.set(checkIn.guest, checkIn);
}
}
The we can extend the app-chain configuration by updating it like so in packages/chain/src/runtime/index.ts
import { Balance } from "@proto-kit/library";
import { Balances } from "./balances";
import { ModulesConfig } from "@proto-kit/common";
import { GuestBook } from "./guest-book";
export const modules = {
Balances,
GuestBook,
};
export const config: ModulesConfig<typeof modules> = {
Balances: {
totalSupply: Balance.from(10_000),
},
GuestBook: {},
};
export default {
modules,
config,
};
With this, we have just implemented a custom runtime module, At this point, we would be able to read its storage or send transactions to it from the client side app-chain. Let's continue and interact with the app-chain, the first step is to start the sequencer. we can do so by using the Protokit CLI, which is available as part of the starter kit under the following command:
pnpm env:inmemory dev --filter chain
The command above will start a local sequencer, which will be available at http://localhost:8080/graphql
.
Every app-chain exposes a transaction API, which is aware of the underlying runtime configuration. This allows us to forge, sign and send transactions to the sequencer without having to worry about the internal workings of the sequencer and its mempool. Let's send a transaction, in our packages/chain/test/
we create our file for testing in a file named interaction.test.ts
.
import { InMemorySigner } from "@proto-kit/sdk";
import { UInt64 } from "@proto-kit/library";
import { client as appChain } from "./../src/client.config";
import { PrivateKey } from "o1js";
import { GuestBook } from "../src/guest-book";
const signer = PrivateKey.random();
const sender = signer.toPublicKey();
describe("interaction", () => {
let guestBook: GuestBook;
beforeAll(async () => {
await appChain.start();
const inMemorySigner = new InMemorySigner();
appChain.registerValue({
Signer: inMemorySigner,
});
const resolvedInMemorySigner = appChain.resolve("Signer") as InMemorySigner;
resolvedInMemorySigner.config = { signer };
guestBook = appChain.runtime.resolve("GuestBook");
});
it("should interact with the app-chain", async () => {
const rating = UInt64.from(3);
const tx = await appChain.transaction(sender, () => {
guestBook.checkIn(rating);
});
await tx.sign();
await tx.send();
});
});
In the above the code does the following
Generate a random signer keypair.
Start the client appChain and register a custom in-memory signer, which will be used to sign transactions
Resolve the GuestBook runtime module, so we can interact with it
Create a transaction, which will call the checkIn method of the GuestBook runtime module
Sign and send the transaction to the locally hosted sequencer.
We can now test by running this command
pnpm test --filter=chain ./test/interaction.test.ts
This brings us to the query API which allows us to fetch the latest state of the app-chain, using the underlying app-chain module configuration. Let's query for the latest check-in for a guest, from our GuestBook runtime module.
We can update our test file from above with
import { InMemorySigner } from "@proto-kit/sdk";
import { UInt64 } from "@proto-kit/library";
import { client as appChain } from "./../src/client.config";
import { PrivateKey, Provable } from "o1js";
import { GuestBook } from "../src/guest-book";
import { sleep } from "@proto-kit/common";
const signer = PrivateKey.random();
const sender = signer.toPublicKey();
describe("interaction", () => {
let guestBook: GuestBook;
beforeAll(async () => {
await appChain.start();
const inMemorySigner = new InMemorySigner();
appChain.registerValue({
Signer: inMemorySigner,
});
const resolvedInMemorySigner = appChain.resolve("Signer") as InMemorySigner;
resolvedInMemorySigner.config = { signer };
guestBook = appChain.runtime.resolve("GuestBook");
});
it("should interact with the app-chain", async () => {
const rating = UInt64.from(3);
const tx = await appChain.transaction(sender, () => {
guestBook.checkIn(rating);
});
await tx.sign();
await tx.send();
await sleep(8000);
const checkIn = await appChain.query.runtime.GuestBook.checkIns.get(sender);
Provable.log("checkIn", sender, checkIn);
});
});
And we run our test command again. One thing to note is that the starter kit comes with a user interface that makes for a flexible reusable interface however we choose to use it
Conclusion
By leveraging Protokit and 01js, we have begun to uncover the potential of zero-knowledge technology within the Mina ecosystem, opening doors to countless exciting possibilities.
Top comments (0)