Building the Frontend
Most of the frontend is already set up for you. However, there are some key functions and constants missing from the frontend. Let's add those now.
First, let's install the frontend dependencies.
cd frontend
npm install
Next, update the frontend/src/utils/constants.ts variables with your deployed contract addresses.
Setting up SSO and Contract Interactions
In order to set up our onchain interactions and SSO wallet connection,
create a new file in the frontend/src/utils folder called wagmi-config.ts:
touch src/utils/wagmi-config.ts
This file will provide all of the onchain functionality the app needs.
Before adding any functions, let's start by adding all of the imports needed at the top of the file:
import {
http,
createConfig,
connect,
getAccount,
writeContract,
waitForTransactionReceipt,
disconnect,
readContract,
} from '@wagmi/core';
import {
zksyncInMemoryNode,
// zksyncSepoliaTestnet
} from '@wagmi/core/chains';
import { type Abi, parseEther } from 'viem';
import { zksyncSsoConnector, callPolicy } from 'zksync-sso/connector';
import { ABI, GAME_CONTRACT_ADDRESS, PAYMASTER_CONTRACT_ADDRESS } from './constants';
import { getGeneralPaymasterInput } from 'viem/zksync';
import type { Score } from './types';
Connectors
The first thing we need to set up is the ssoConnector:
const ssoConnector = zksyncSsoConnector({
authServerUrl: 'http://localhost:3002/confirm',
session: {
expiry: '1 hour',
feeLimit: parseEther('0.01'),
contractCalls: [
callPolicy({
address: GAME_CONTRACT_ADDRESS,
abi: ABI as Abi,
functionName: 'verifyProof',
}),
],
},
});
This connector configures:
- The
authServerUrlthat the wallet will connect to, as we will be running this locally. - The session time expiry limit.
- The maximum amount of transaction fees that can be used in the session.
- That the session can only call the
verifyProoffunction in our game contract.
Next, we need to define the wagmi configuration object.
export const config = createConfig({
chains: [
// zksyncSepoliaTestnet,
zksyncInMemoryNode,
],
connectors: [ssoConnector],
transports: {
// [zksyncSepoliaTestnet.id]: http(),
[zksyncInMemoryNode.id]: http(),
},
});
This object configures the chains and wallet connectors available for the user. If you want to configure the network to ZKsync Sepolia Testnet, or use another wallet connector, this is where you can make those changes.
connectWithSSO
Now that we have the wagmi config object and SSO connector, we can make a new function called connectWithSSO for the user to connect.
export const connectWithSSO = () => {
connect(config, {
connector: ssoConnector,
chainId: config.chains[0].id,
});
};
disconnectWallet
To let the user disconnect, let's add another function called disconnectWallet.
export const disconnectWallet = async () => {
await disconnect(config);
};
Reading the Contract
To read the top score for a given player and the top 10 high scores for game overall,
add the getPlayerHighScore and getHighScores functions below:
export async function getPlayerHighScore(playerAddress: `0x${string}`) {
const data = await readContract(config, {
abi: ABI,
address: GAME_CONTRACT_ADDRESS,
functionName: 'getPlayerScore',
args: [playerAddress],
});
return data;
}
export async function getHighScores() {
const highScores: Score[] = [];
const data = await readContract(config, {
abi: ABI,
address: GAME_CONTRACT_ADDRESS,
functionName: 'getTopScores',
});
if (Array.isArray(data)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const typedData: Score[] = data as any;
typedData.forEach((score) => {
const newHighScore: Score = {
player: score.player,
blocksDestroyed: score.blocksDestroyed,
timeElapsed: score.timeElapsed,
};
highScores.push(newHighScore);
});
}
if (highScores.length < 10) {
for (let i = highScores.length; i < 10; i++) {
highScores.push({
player: '0x0000000000000000000000000000000000000000',
blocksDestroyed: 0n,
timeElapsed: 0n,
});
}
}
return highScores;
}
Writing to the Contract
Finally, we can add a function to allow users to write to the contract with their high scores.
export async function verifyProof(publicValues: string, proofBytes: string) {
if (!publicValues || !proofBytes) {
alert('Proof data not found');
return;
}
const account = getAccount(config);
if (!account.address) {
alert('Account not found');
return;
}
const txData = {
from: account.address,
abi: ABI,
address: GAME_CONTRACT_ADDRESS,
functionName: 'verifyProof',
args: [publicValues, proofBytes],
paymaster: PAYMASTER_CONTRACT_ADDRESS,
paymasterInput: getGeneralPaymasterInput({ innerInput: '0x' }),
chainId: config.chains[0].id,
connector: config.connectors[0],
};
const txHash = await writeContract(config, txData);
const transactionReceipt = await waitForTransactionReceipt(config, {
hash: txHash,
});
return transactionReceipt.status === 'success';
}
The verifyProof function:
- Calls the corresponding function in the game contract.
- Passes the
publicValuesandproofBytesarguments into the contract function. - Uses the paymaster contract deployed earlier to pay for the transaction so the user doesn't need any funds to play.
- Finally returns
trueorfalsedepending on whether the transaction succeeded.
Generating Proofs
Make a new file in the same folder called proofs.ts:
touch src/utils/proofs.ts
In the backend folder, there is an API already set up to handle requests to create a proof using the ELF binary produced by the SP1 program.
Instead of using mock game data like we did in the script,
we can send the actual user's recorded game actions and final score to the API to create a proof.
Let's add a function called submitProof to do this:
import { HTTP_API_URL, WS_API_URL } from './constants';
import type { Action } from './types';
export async function submitProof(actionLog: Action[], blocksDestroyed: number, timeElapsed: number) {
try {
if (!blocksDestroyed || !timeElapsed || actionLog.length === 0) {
alert('Invalid proof data');
return;
}
const jobID = await requestProof(actionLog, blocksDestroyed, timeElapsed);
if (!jobID) {
return;
}
const result = await getProofResult(jobID);
return result.proof_data;
} catch (error) {
console.error('Error creating proof:', error);
}
}
This function:
- Takes three arguments: the recorded
actionLog, the totalblocksDestroyedby the player, and thetimeElapsedaccording to the game library. - Sends the arguments to the API and receives back a
jobID. - Waits until the proof is ready, and uses the
jobIDto get the finalproofData. - Returns the final
proofDataobject, which contains both thepublicValuesandproofByteshash strings required for theverifyProoffunction added earlier.
Next, add the remaining helper functions below.
async function requestProof(actionLog: Action[], blocksDestroyed: number, timeElapsed: number) {
try {
const inputData = JSON.stringify({
action_log: actionLog,
blocks_destroyed: blocksDestroyed,
time_elapsed: timeElapsed,
});
const response = await fetch(`${HTTP_API_URL}/prove`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: inputData,
});
const { job_id: jobID } = await response.json();
return jobID;
} catch (error) {
console.error('Error requesting proof:', error);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function getProofResult(jobID: string): Promise<any> {
return new Promise((resolve, reject) => {
const wsEndpoint = `${WS_API_URL}/proof/${jobID}`;
const socket = new WebSocket(wsEndpoint);
socket.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
if (data.status === 'processing') {
console.log('Proof is being processed...');
} else if (data.status === 'complete') {
socket.close();
resolve(data);
} else if (data.status === 'failed' || data.status === 'not_found') {
reject(new Error('Proof generation failed'));
}
});
socket.addEventListener('error', (error) => {
console.error('WebSocket error:', error);
socket.close();
reject(error);
});
setTimeout(() => {
socket.close();
reject(new Error('WebSocket connection timed out'));
}, 5 * 60000); // 5 min timeout
});
}
Running the App
Follow the steps below to test and run the full app locally.
- To test with ZKsync SSO locally, you will need to clone the
zksync-ssorepository, and follow the steps in the README to launch the example demo app and local auth server. Make sure the auth server is running at the same URL configured in thessoConnector. - Launch the API service to generate proofs using your SP1 private key.
Move back into the root folder, and run the command below.
It will run at the API your local
8000port.cd .. SP1_PROVER=network NETWORK_RPC_URL=https://rpc.production.succinct.xyz NETWORK_PRIVATE_KEY=<YOUR_PRIVATE_KEY> RUST_LOG=info cargo run -p api --release - Build and compile the game for web. This will create a
wasmfolder inside thefrontendfolder. If you don't havewasm-bindgeninstalled already, run the first command below to install it.cargo install -f wasm-bindgen-cli
Then open a new terminal in the root folder of the project, and run the build command below.cargo build -p web-game --release --target wasm32-unknown-unknown && wasm-bindgen --out-name game_wasm --out-dir frontend/wasm --target web target/wasm32-unknown-unknown/release/web_game.wasm - Finally, you can run the frontend app from the
frontendfolder:cd frontend npm run dev
Now that everything is set up, you can finally test out the app!
It should have opened for you, but if not, you can find it running at http://localhost:5173/.
All icons are configured to open using a double-click.
Double click on the "High Scores" icon to see the score submitted from the interact script.
To play the game, first open the "Log In" button to log in with SSO. Then, double click on the "Games" icon to open the games folder. Double click again on the "Brickles" game to open the game window.
Click the text at the top to start the game, and use the arrow keys on your keyboard to move the paddle left or right.
Once you get a score you are happy with, you can click the "SAVE ON CHAIN" text to generate a proof of your recorded movements, score, and time.
Then, using the session you created when signing in, the app will submit the proof data to the game contract to verify your score onchain.
Once completed, you can open the "High Scores" window to view your new high score in the top 10 ranking.
Next Steps
Ready to build more ZK games?
You can replace the game_lib with any other deterministic game written in Rust!