Building the Contracts
Project Setup
To get started, clone the starting template into a folder called zk-game
:
git clone https://github.com/sarahschwartz/zk-game-tutorial-template.git zk-game
In the template, you will find the basic game utilities set up for you, including:
- a
backend
folder which contains an API to handle the requests for proofs, - a
frontend
folder with most of the frontend application set up already, - and a
game_lib
folder, which contains a Rust library for the game mechanics.
What's missing from the template are the contracts, prover program, SSO wallet, and onchain interactions. Let's get started by adding the contracts first.
Adding Contracts
Move into the zk-game
folder and create a new contracts
folder using the hardhat_solidity
template:
cd zk-game
npx zksync-cli create contracts --template hardhat_solidity
Once that is ready, move into the new contracts
folder.
cd contracts
Once that is ready, move into the new contracts
folder,
and run the command below to move the GeneralPaymaster
contract to the contracts/contracts
folder,
and delete the template files we won't need.
mv ./contracts/paymasters/GeneralPaymaster.sol ./contracts/
find ./contracts -mindepth 1 ! -name 'GeneralPaymaster.sol' -delete
rm -rf ./scripts/*
rm -rf ./test/*
We will be testing our game on a local node.
To deploy the contracts to our local node,
modify the defaultNetwork
on line 9 inside the hardhat.config.ts
to set it to anvilZKsync
.
defaultNetwork: 'anvilZKsync',
Game Contract Code
Next, create a new file for our game contract called Brickles.sol
.
touch contracts/Brickles.sol
Copy and paste the full contract below into the file.
Understanding the Contract
Let's break down what's in this contract, and how it works.
PublicValuesStruct
At the top of the contract file is the PublicValuesStruct
.
This struct defines the public values that will be produced by the proof.
Remember: our prover program will have private inputs and public outputs. The private inputs will include the player's actions and final game score. The public outputs will include whether or not the action inputs produce the same final game score that they claimed as well as the score itself.
Storage Values
There are several persistent storage variables in our contract:
verifier
: stores the address of the verifier contract.gameProgramVKey
: stores the program verification key for the prover program.verifiedProofs
: stores all of the verified proofs used.topScores
: keeps track of top 10 high scores across all players.playerBestScores
: stores the high score for each player.
Constructor Function
When the contract is deployed, it requires that contract address of the verifier and the gameProgramVKey
in order to initialize those values.
There are no methods to change these variables, so if they need to be changed, a new deployment would be required.
verifyProof
Function
The only writable function available for players to call is the verifyProof
function.
It takes the proof and public values hashes,
verifies them with the verifier contract,
and if valid, saves the player's score.
This function first checks to make sure the submitted proof isn't empty and hasn't been used by another player before.
Then, it calls the verifyProof
function from the verifier contract.
Once verified, it checks to make sure the result of the program was valid, and saves the high score and proof in persistent storage.
Read Functions
The getTopScores
and getPlayerScore
functions are available to get the top 10 high scores and get the highest score for a given player respectively.
Verifier Contract Code
We will also need the verifier contract in order to verify the game proofs.
These contracts are provided by SP1.
We will be using version 4.0.0-rc.3
of the SP1 verifier contracts.
There are three contracts we need to copy:
Add each of these contracts into your contracts/contracts
folder.
Make sure to update the import path for ISP1Verifier.sol
at the top of the SP1VerifierGroth16.sol
contract.
With each of these added, your contracts/contracts
folder should look like this:
├── Brickles.sol
├── GeneralPaymaster.sol
├── Groth16Verifier.sol
├── ISP1Verifier.sol
└── SP1VerifierGroth16.sol
1 directory, 5 files
Deploying Contracts
Create three new files inside the contracts/scripts
folder for the verifier, paymaster, and game contract deploy scripts.
touch scripts/deploy-verifier.ts scripts/deploy-paymaster.ts scripts/deploy-game.ts
Edit your contracts/package.json
file so we can easily run the scripts later:
"deploy:game": "hardhat run ./scripts/deploy-game.ts",
"deploy:verifier": "hardhat run ./scripts/deploy-verifier.ts",
"deploy:paymaster": "hardhat run ./scripts/deploy-paymaster.ts",
Starting a Local Node
Open Docker Desktop so the Docker daemon is running in the background,
and use zksync-cli
to start a local in-memory node:
npx zksync-cli dev start
Then, copy of one the private keys from the pre-configured rich wallets list,
and update the contracts/.env
file with the private key like the example below:
WALLET_PRIVATE_KEY=0x3d3cbc973389cb26f657686445bcc75662b415b656078503592ac8c1abb8810e
Deploying the Verifier Contracts
Let's start with the verifier contract. Copy and paste the deploy script:
import { deployer } from 'hardhat';
async function main() {
const artifact = await deployer.loadArtifact('SP1Verifier');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const constructorArguments: any[] = [];
const contract = await deployer.deploy(artifact, constructorArguments);
console.log('DEPLOYED VERIFIER CONTRACT ADDRESS: ', await contract.getAddress());
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Next, compile and deploy the contract from inside the zk-game/contracts
folder:
npm run compile
npm run deploy:verifier
Save the deployed contract address somewhere so we can use it later.
Deploying the Paymaster Contract
Next, let's add the script to deploy and fund the paymaster contract.
import { deployer, ethers } from 'hardhat';
async function main() {
const artifact = await deployer.loadArtifact('GeneralPaymaster');
const [signer] = await ethers.getSigners();
const constructorArguments = [signer.address];
const contract = await deployer.deploy(artifact, constructorArguments);
const address = await contract.getAddress();
console.log('DEPLOYED PAYMASTER CONTRACT ADDRESS: ', address);
// Fund the paymaster contract
const tx = await signer.sendTransaction({
to: address,
value: ethers.parseEther('0.5'),
});
await tx.wait();
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Then, run the script, and save this contract address as well.
npm run deploy:paymaster
Deploying the Game Contract
Finally, let's set up the deploy script for the game contract.
import { deployer } from 'hardhat';
async function main() {
// sp1 verifier contract v4.0.0-rc.3
const VERIFIER_CONTRACT_ADDRESS = process.env.VERIFIER_CONTRACT_ADDRESS ?? '0x...';
// generated in our sp1 script
const PROGRAM_VERIFICATION_KEY = process.env.PROGRAM_VERIFICATION_KEY ?? '0x...';
const artifact = await deployer.loadArtifact('Brickles');
const constructorArguments = [VERIFIER_CONTRACT_ADDRESS, PROGRAM_VERIFICATION_KEY];
const contract = await deployer.deploy(artifact, constructorArguments);
console.log('DEPLOYED GAME CONTRACT ADDRESS: ', await contract.getAddress());
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
In our deploy script for the game, we need both the verifier contract address and the verification key for our prover program.
Update the VERIFIER_CONTRACT_ADDRESS
variable with the deployed address from earlier.
However, we need to wait to deploy this contract until we have the PROGRAM_VERIFICATION_KEY
.
We will do that in the next section.