Protokit tips & tricks: our experience + bonus part
Protokit is a popular app-chain solution in the Mina world. It allows developers to launch their own sequencer that proves state changes to the Mina network enabling fast transactions and low fees. Protokit use o1js types allowing contracts to look just like the Mina contracts with some features and removed restrictions. Tho a lot of new possibilities are here, sometimes developer can find a hard time trying to cook it. In this article we want to share some ZkNoid team experience, tips and tricks dealing with protokit. Common issues are here as well as configuration examples and infrastructure setting up. In the bonus part it’s described how to enable optimizations without breaking the appchain client.
Showcases
There are several early birds who managed to set up CI scripts for protokit sequencer deployments. The most known are ZkNoid repo and Xendarboh’s protokit-starter-kit repo. We’ll overview approaches implementation in both of the repos
Initial chain data initialization
Most of developers used to have a constructor in a smartcontract that allows to initialize some on-chain data when deployed. However in protokit world there is a predefined set of contracts with no constructor support. To achieve initial data setting, ZkNoid use a special script. When network is launched and sequencer is initialized, it pushes the initial data to the network.
packages/chain/on_startup_scripts/set-default-games.ts:
import { PrivateKey, UInt64 } from 'o1js';
import { getDefaultCompetitions, client } from '../src';
const setDefaultGames = async () => {
const alicePrivateKey = PrivateKey.random();
const alice = alicePrivateKey.toPublicKey();
await client.start();
const gameHub = client.runtime.resolve('ArkanoidGameHub');
const defaultCompetitions = getDefaultCompetitions();
for (let i = 0; i < defaultCompetitions.length; i++) {
const competition = defaultCompetitions[i];
const tx = await client.transaction(alice, () => {
gameHub.createCompetition(competition);
});
tx.transaction!.nonce = UInt64.from(i);
tx.transaction = tx.transaction?.sign(alicePrivateKey);
await tx.send();
}
};
await setDefaultGames();
Then a special command is created in chain package that calls the init script. And it’s launched with server startup. Package concurrently
is used here to launch two commands at the same time
packages/chain/package.json#L26
"scripts": {
"on-startup-scripts": "sleep 8 && node --experimental-specifier-resolution=node --experimental-vm-modules --experimental-wasm-modules --experimental-wasm-threads dist/on_startup_scripts/set-default-games.js",
"start-server": "tsc -p tsconfig.json && concurrently 'pnpm on-startup-scripts' 'pnpm custom-protokit'",
},
"dependencies": {
"concurrently": "^8.2.2",
}
Disabling default sequencer UI
When launching an appchain for a long time you may face the following error — FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed — JavaScript heap out of memory. This happens because of the console UI memory leak. When network is supposed to run for a long period of time, custom loader is a must have
#!/usr/bin/env node --experimental-specifier-resolution=node --experimental-vm-modules --experimental-wasm-modules --experimental-wasm-threads
import { ManualBlockTrigger } from '@proto-kit/sequencer';
import appChain from '../src/chain.config';
import { exit } from 'process';
await appChain.start();
const trigger = appChain.sequencer.resolveOrFail(
'BlockTrigger',
ManualBlockTrigger,
);
setInterval(async () => {
console.log('Tick');
try {
await trigger.produceUnproven();
} catch (e) {
console.error('Run err', e);
}
}, 5000);
Then in package.json a special command added allowing to launch protokit with our custom loader package.json#L21C19-L21C67
"custom-protokit": "tsc -p tsconfig.json && node --experimental-specifier-resolution=node --experimental-vm-modules --experimental-wasm-modules --experimental-wasm-threads dist/bin/run.js",
"start": "custom-protokit start ./dist/src/chain.config.js",
Sequencer deployment
Protokit offers starter-kit that works pretty well if you launch project locally. However what if you want to share you project with other users? To achieve this you need to deploy your own sequencer to a server.
ZkNoid offers docker config that allows to launch the protokit server: docker-compose.yml. So the entire protokit sequencer launches with just a docker-compose up
command
It utilizes start-server
script in the chain package that use already mentioned data initialization and custom loader support. https://github.com/ZkNoid/zknoid/blob/72bffce52a11966dbcc2ab0728d45196ec168dfe/packages/chain/package.json#L25
Xendarboh’s example offers docker/chain/docker-compose.yml file to set up the sequencer with a similar start-server command
Persistent storage
Currently persistent storage is very unstable. However Xendarboh’s project shows how to use it in feature/persistance
branch.
It offers setup-prisma.sh script that sets up redis and prisma for persistence data storing.
This script is used docker setup allowing app-chain data to be stored.
Webpack duplication
In some cases if you build client on front-end it may happen that getters may return default values instead of the real ones. The reason is that protokit widely use instanceof internally and webpack sometimes includes package two times into different chunks. E.g. in query factory you can find proto_kit_protocol__WEBPACK_IMPORTED_MODULE_0_/* .StateMap */ .Oh type while in other place proto_kit_protocol__WEBPACK_IMPORTED_MODULE_1_/* .StateMap */ .Oh is used that broke instanceof call.
const QueryBuilderFactory = {
fillQuery(runtimeModule, queryTransportModule) {
let query = {};
for (const propertyName in runtimeModule) {
const property = runtimeModule[propertyName];
// This check doesn't work for property because of diffenrent webpack module
if (property instanceof _proto_kit_protocol__WEBPACK_IMPORTED_MODULE_0__/* .StateMap */ .Oh) {
....
...
let Balances = class Balances extends _proto_kit_module__WEBPACK_IMPORTED_MODULE_0__/* .RuntimeModule */ .ot {
constructor() {
super(...arguments);
// StateMap from other webpack module used
this.balances = _proto_kit_protocol__WEBPACK_IMPORTED_MODULE_1__/* .StateMap */ .Oh.from(o1js__WEBPACK_IMPORTED_MODULE_2__/* .PublicKey */ .nh, o1js__WEBPACK_IMPORTED_MODULE_2__/* .UInt64 */ .zM);
The solution is to export ClientAppChain
type directly from chain package and import it instead of ClientAppChain from protokit library. Here is an example https://github.com/ZkNoid/zknoid/blob/develop/packages/chain/src/index.ts#L17
// chain/src/index.ts
export { ClientAppChain, ProtokitLibrary, UInt64 as ProtoUInt64 };
// apps/web/lib/createConfig.ts
import { type ClientAppChain } from 'zknoid-chain-dev';
Logging
Sometimes it’s useful to print contract variables to debug an issue. There are two ways to implement logging within a contract: Provable.log
and Provable.asProver
.
First one receives provable variables in arguments and print their values in the sequencer logs:
Provable.log(player1, player2, player1Share, player2Share, totalShares);
Second allows to run a ts code on the prover, leaving provable context. It supports to call regular console.log
. Just do not to forget to convert provable types to printable ones. Example:
Provable.asProver(() => {
if (gameOption.isSome.toBoolean()) {
console.log('capturedCellX: ', capturedCellX.toString());
console.log('capturedCellY: ', capturedCellY.toString());
console.log('moveToX: ', moveToX.toString());
console.log('moveToY: ', moveToY.toString());
}
});
Protokit tracing, asserts and o1js int types issues
Before calling contract with the actual state, protokit traces contract execution with default values. This leads to several consequences.
Firstly, logs may be printed several times with strange values. To prevent this, they needs to check that expected values exist and not equal to default values.
Second is that contracts can’t throw hard exceptions even on default values. Otherwise tracing will fail and appchain won’t launch. To avoid this issue, protokit implemented their own asserts with soft fails that are processed correctly by protokit appchain. As the result default o1js asserts like .assertEquals()
can't be used in the protokit code.
import { assert } from '@proto-kit/protocol';
// Incorrect, appchain won't launch
fromBalance.assertGreaterThan(amount)
// Correct
assert(fromBalance.greaterThan(amount))// Incorrect, appchain won't launch
game.value.currentMoveUser.equals(sender).assertTrue('Not your move');
// Correct
assert(game.value.currentMoveUser. equals(sender), 'Not your move');
But that’s not all. Default o1js Uint types use hard failures in cases like subtraction from smaller number or division by 0. This may broke tracing as well because of default values passing. For this situation protokit implemented their own numeric types that have soft assertions
// Using o1js numeric types, may broke entire appchain on tracing
import { UInt64 } from 'o1js';
// Using protokit numeric types, better solution
import { UInt64 } from '@proto-kit/library';
Protokit types re-instantiation
From official proto-kit discord, if you’re using @proto-kit/library
UInt64
in combination with the state API, please make sure to re-instantiate the UInt64 after fetching it from state. This is necessary due to the inability of the state API to effectively reinstantiate the UInt64 struct from the underlying state fields. Protokit developers are working on a fix, in the meantime use it like this:
@state() public foo = State.from<UInt64>(UInt64);
@runtimeMethod()
public bar() {
const fooOption = this.foo.get();
const foo = UInt64.from(fooOption.value.value); // use the UInt64 as you normally would from this point onwards
}
full example: library/src/runtime/Balances.ts#L63
Bonus: minification issue & fix
As protokit and o1js libraries are weighty it’s important to use code optimizations. However with default optimization’s set protokit throws Attempted to resolve unregistered dependency token: “u” exception in production build:
926.7c383f76095ebdd0.js:14 Uncaught (in promise) Error: Attempted to resolve unregistered dependency token: "u"
at InternalDependencyContainer.resolve (926.7c383f76095ebdd0.js:14:6624)
at c.resolve (926.7c383f76095ebdd0.js:17:1942)
at ClientAppChain.transaction (926.7c383f76095ebdd0.js:41:7934)
at startGame (119.e77ce8332feaed1b.js:1:3234)
This is because minification renames classes when protokit DI solution is tightly bound to the class names. So before it was suggested to disable optimization completely. However in ZkNoid we researched the issue and found a solution. Here is how apps/web/next.config.js should look like to disable only those optimizations that broke protokit:
config.experiments = { ...config.experiments, topLevelAwait: true };
return {
...config,
optimization: {
minimize: true,
minimizer: [new TerserPlugin({
terserOptions: {
compress: {
keep_classnames: true,
keep_fnames: true
},
mangle: {
keep_classnames: true,
keep_fnames: true
},
}
})]
}
};
},
Reference: https://github.com/ZkNoid/zknoid/blob/develop/apps/web/next.config.js#L33
Now you can still minify and optimize the project code with appchain still working