ZkNoid Patterns Overview: Factory of Lottery Rounds
Implementing a project on the Mina protocol presents a unique set of challenges. Our journey in developing the ZkNoid lottery game has been a testament to this complexity. Throughout the process, we engaged in extensive research and devised innovative strategies to ensure that our zkApp is both sustainable and reliable.
In this series of articles, we will delve into the intricacies of the ZkNoid lottery contracts operating on Mina’s L1. We will systematically explore the code, shedding light on the design patterns and architectural choices we made during implementation. By sharing our experiences and insights, we aim to provide valuable knowledge for developers and enthusiasts looking to navigate the Mina ecosystem.
Factory pattern
ZkNoid lottery game process is divided into rounds. Within a round users buy tickets. After round end, the winning combination is generated for the round and users can claim their rewards.
Round store such information as
- Start date
- Bought tickets merkle root
- Bought tickets nullifier
- Bank of the round
- Winning combination of the round
- Total score of the players in round
Problem statement
Our initial approach was to create a single contract for the lottery. In this contract rounds related information was stored as merkle maps like mapping(uint256 roundId => TicketInfo)
in terms of solidity. The initial approach can be found here.
However it become clear that this approach has a lot of drawbacks.
Concurrency problem
Because of concurrency problem on Mina it’s become impossible to concurrently claim rewards in different rounds without having one transaction to be failed.
Constantly increased merkle maps initialization complexity
To build every new round merkle trees for witnesses generation it was required to load all the previous round infos. With next lottery rounds coming time to initialize merkle maps grows further and further.
Higher time to compile zk program
Data structures become more complicated when we store merkle maps of data linked to round if instead of storinf just the data. This leads to larger circuit size and greater compilation size.
Reduce proof validation requires buying a ticket in new round
After round end it’s required to submit the new tickets merkle root with a proof of reduced round tickets. This proof verifies that the proposed root is valid and includes all the actions of tickets buyings. However when new rounds starts, buying tickets in it affects the actions state root value and it can’t be used to check reduce proof.
Taking into account the features of action state implementation in Mina, it can check that a specific action state is presented within 5 latest action states stored. The only way to check that the latest ticket was reduced is to wait for a ticket in new round bought. This requires a backend mechanism that automatically buys empty ticket to make system work which is not so convenient. Read more about this issue in article about reducers.
Solution
To overcome the described challenges we implemented a factory pattern. For every round a separate contract is deployed by factory. Now every round contract stores only the information related to the round and makes rounds work separately.
Factory contracts implementation
To deploy instances, factory needs to know their verification keys that would be set to the deployed accounts. Also factory needs to know which rounds was deployed. The full source code of the factory can be found here.
We’re going to store instances VKs as constants in our smartcontract
const randomManagerVK = {
hash: Field(vkJSON.randomManagerVK.hash),
data: vkJSON.randomManagerVK.data,
};
const PLotteryVK = {
hash: Field(vkJSON.PLotteryVK.hash),
data: vkJSON.PLotteryVK.data,
};
And store in state the root of the merkle map of the deployed round infos by their id
export class PlotteryFactory extends SmartContract {
...
@state(Field) roundsRoot = State<Field>();
Once we want to deploy a new round, we need to create the new accounts for round random manager and round lottery and initialize them. We create two keypairs for our instances and sign the transactions with them. We pass their public keys to the deploy function with the witness of deployed rounds info merkle map change.
So the deploy function signature looks like
@method
async deployRound(
witness: MerkleMapWitness,
randomManager: PublicKey,
plottery: PublicKey
) {
First we need to update information about deployed rounds to prohibit several round deployments. Our merkle map stores integer flag, `0` or `1` whether a specific round was deployed. We use witness to update the merkle map. We require that witness for the expected round id contains 0
as value and updating merkle root with the one storing 1
as a value for the round id
// Check if round was not used and update merkle map
const curRoot = this.roundsRoot.getAndRequireEquals();
// Witness for round id should contain 0 as value
const [expectedRoot, round] = witness.computeRootAndKeyV2(Field(0));
curRoot.assertEquals(expectedRoot, 'Wrong witness');
// Re-calculating root with value 1 for round id
const [newRoot] = witness.computeRootAndKeyV2(Field(1));
// Setting new root
this.roundsRoot.set(newRoot);
In deploy function we also need to initialize accounts with the correct verification keys. We create account updates that initialize accounts with correct verification keys, state slots and permissions. Permission changing prohibits private key holders to approve any further action with deployed instances.
First we deploy random manager
const rmUpdate = AccountUpdate.createSigned(randomManager);
rmUpdate.account.verificationKey.set(randomManagerVK);
rmUpdate.update.appState[0] = {
isSome: Bool(true),
value: localStartSlot,
};
// Update permissions
rmUpdate.body.update.permissions = {
isSome: Bool(true),
value: {
...Permissions.default(),
},
};
rmUpdate.body.update.appState[4] = {
isSome: Bool(true),
value: hashPart1,
};
rmUpdate.body.update.appState[5] = {
isSome: Bool(true),
value: hashPart2,
};
Then we initialize lottery manager that has random manager address as one of the fields. Public key takes two memory slots in storage so we need to convert it to fields
const plotteryUpdate = AccountUpdate.createSigned(plottery);
plotteryUpdate.account.verificationKey.set(PLotteryVK);
// Set random manager
const rmFields = randomManager.toFields();
// Random manager address
plotteryUpdate.update.appState[0] = {
isSome: Bool(true),
value: rmFields[0],
};
plotteryUpdate.update.appState[1] = {
isSome: Bool(true),
value: rmFields[1],
};
// Start slot set
plotteryUpdate.update.appState[2] = {
isSome: Bool(true),
value: localStartSlot,
};
// Set ticket ticketRoot
plotteryUpdate.update.appState[3] = {
isSome: Bool(true),
value: new MerkleMap20().getRoot(),
};
// Set ticket nullifier
plotteryUpdate.update.appState[4] = {
isSome: Bool(true),
value: new MerkleMap20().getRoot(),
};
// Update permissions
plotteryUpdate.body.update.permissions = {
isSome: Bool(true),
value: {
...Permissions.default(),
},
};
After this we’re emitting an event with the deployed instances addresses to be able to fetch the deployed instances in future
this.emitEvent(
'deploy-plottery',
new DeployEvent({ round, plottery, randomManager })
);
Using factory
To call the deploy function of the factory we need to prepare keypairs for the instances and use them to sign transaction
let plotteryPrivateKey = PrivateKey.random();
let plotteryAddress = plotteryPrivateKey.toPublicKey();
let randomManagerPrivateKey = PrivateKey.random();
let randomManagerAddress = randomManagerPrivateKey.toPublicKey();
console.log(
`Deploying plottery: ${plotteryAddress.toBase58()} and random manager: ${randomManagerAddress.toBase58()} for round ${round}`
);
let tx = await Mina.transaction(
{ sender: deployer, fee: 10 * transactionFee },
async () => {
AccountUpdate.fundNewAccount(deployer);
AccountUpdate.fundNewAccount(deployer);
await factory.deployRound(witness, randomManagerAddress, plotteryAddress);
}
);
await tx.prove();
let txInfo = await tx
.sign([deployerKey, randomManagerPrivateKey, plotteryPrivateKey])
.send();
const txResult = await txInfo.safeWait();
Conclusion
We overviewed one of ZkNoid concepts invented during lottery game implementation. In the next article, we will overview other design patterns that became the core of the ZkNoid platform.