Writing The Program
Now that we have our contracts ready, the next thing we will do is create our program.
This program will be used to generate the verification key used in the Brickles.sol
smart contract.
The verification key is used to verify that the code being executed by the prover is the same code that we expect.
The program will do three things:
- Read the private inputs, which will include the recorded player actions, and the final score and time that they claim they achieved.
- Use the inputs to re-run the game program using the
verify_replay
method in the game library. This method will returntrue
if the claimed final score and time matches what was produced from running the game using the recorded actions, andfalse
if it does not match. - Output the encoded result of
verify_replay
as well as the final score and time, so it can be decoded inside our Solidity contract. ThePublicValuesStruct
coming from the game library matches the one inside theBrickles.sol
contract.
Program Setup
Move back into the root folder of the template project,
and create a Rust project inside the backend
folder named sp1_program
.
cd ..
cargo new backend/sp1_program
Next, update the Cargo.toml
file inside backend/sp1_program
with the dependencies below:
[dependencies]
alloy-sol-types = { workspace = true }
sp1-zkvm = "4.0.0-rc.3"
game-lib = { path = "../../game_lib" }
Then, replace the code inside src/main.rs
with our prover program:
// These two lines are necessary for the program to properly compile.
//
// Under the hood, we wrap your main function with some extra code so that it behaves properly
// inside the zkVM.
#![no_main]
sp1_zkvm::entrypoint!(main);
use alloy_sol_types::SolType;
use game_lib::{Action, GameState, PublicValuesStruct};
pub fn main() {
let action_log = sp1_zkvm::io::read::<Vec<Action>>();
let claimed_blocks_destroyed = sp1_zkvm::io::read::<u32>();
let claimed_time_elapsed = sp1_zkvm::io::read::<f32>();
let result =
GameState::verify_replay(action_log, claimed_time_elapsed, claimed_blocks_destroyed);
let bytes = PublicValuesStruct::abi_encode(&PublicValuesStruct {
blocksDestroyed: claimed_blocks_destroyed,
timeElapsed: (claimed_time_elapsed * 1000.) as u32,
isValid: result,
});
sp1_zkvm::io::commit_slice(&bytes);
}
Building the Program
Install the cargo-prove
CLI using the commands below:
curl -L https://sp1.succinct.xyz | bash
sp1up
If you have any issue installing the CLI, check out the official documentation.
Once that is installed, use cargo-prove
to build the program.
This will build the ELF binary file required by SP1.
The API in the backend
folder depends on this file to generate proofs.
cd backend/sp1_program
cargo-prove prove build --output-directory ../elf
Creating a Proof
We can test out our program with a script.
Create a new script in the backend
folder with the command below.
cd ..
cargo new script
Then, update the dependencies in the backend/script/Cargo.toml
config.
[dependencies]
sp1-sdk = "4.0.0-rc.3"
hex = "0.4.3"
alloy-sol-types = { workspace = true }
game-lib = { path = "../../game_lib" }
Finally, copy and paste the script below into backend/script/src/main.rs
.
use alloy_sol_types::SolType;
use game_lib::*;
use sp1_sdk::{HashableKey, ProverClient, SP1Stdin};
/// The ELF (executable and linkable format) file for the Succinct RISC-V zkVM.
pub const ELF: &[u8] = include_bytes!("../../elf/sp1_program");
fn main() {
// Setup the logger.
sp1_sdk::utils::setup_logger();
// Setup the prover client.
let client = ProverClient::from_env();
// Setup the inputs.
let mut stdin = SP1Stdin::new();
let test_actions: Vec<Action> = vec![
Action {
direction: Controls::None,
count: 26,
},
Action {
direction: Controls::Right,
count: 28,
},
Action {
direction: Controls::None,
count: 124,
},
Action {
direction: Controls::Right,
count: 13,
},
Action {
direction: Controls::None,
count: 95,
},
Action {
direction: Controls::Left,
count: 29,
},
Action {
direction: Controls::None,
count: 51,
},
Action {
direction: Controls::Left,
count: 28,
},
Action {
direction: Controls::None,
count: 227,
},
];
let actual_final_score: u32 = 6;
let actual_time_elapsed: f32 = 10.35;
stdin.write(&test_actions);
stdin.write(&actual_final_score);
stdin.write(&actual_time_elapsed);
println!("Executing program...");
let (output, _report) = client.execute(ELF, &stdin).run().unwrap();
println!("Program executed successfully.");
let decoded = PublicValuesStruct::abi_decode(output.as_slice(), true).unwrap();
println!("blocks_destroyed: {:?}", decoded.blocksDestroyed);
println!("time_elapsed: {:?}", decoded.timeElapsed);
println!("is_valid: {:?}", decoded.isValid);
assert!(decoded.isValid);
let (pk, vk) = client.setup(ELF);
let vk_bytes = vk.bytes32();
println!("V KEY: {:?}", vk_bytes);
// GENERATE THE PROOF
println!("generating proof...");
let proof = client.prove(&pk, &stdin).groth16().run().unwrap();
println!("successfully generated proof for the program!");
let public_values = proof.public_values.as_slice();
let final_public_values = hex::encode(public_values);
println!("public values: 0x{}", final_public_values);
let solidity_proof = proof.bytes();
let final_solidity_proof = hex::encode(solidity_proof);
println!("proof: 0x{}", final_solidity_proof);
}
This script:
- Sets up the SP1 Client.
- Inputs a mock player action log, final score, and time.
- Executes the prover program using the ELF binary.
- And generates a proof.
Using the private key of your wallet that has access to the SP1 network, run the script.
SP1_PROVER=network NETWORK_RPC_URL=https://rpc.production.succinct.xyz NETWORK_PRIVATE_KEY=<YOUR_PRIVATE_KEY> RUST_LOG=info cargo run -p script --release
The script should log the final proof hash and public data hash.
Getting the Verification Key
The verification key should have been logged in the script as V KEY: "0x.."
.
Now that we have the verification key, update the PROGRAM_VERIFICATION_KEY
variable in the contracts/scripts/deploy-game.ts
script.
With that updated, go back into the contracts
folder and deploy the game contract.
cd ../contracts
npm run deploy:game
Save the deployed contract address for the next section.
Testing the Contract
Now that we have an example proof generated, let's test out the contract.
Add a new file inside the contracts/scripts
folder called interact.ts
.
touch scripts/interact.ts
Copy and paste the script below, and replace the GAME_CONTRACT_ADDRESS
and PAYMASTER_CONTRACT_ADDRESS
with your deployed contract addresses.
Then, copy and paste the public values
and proof
logged from the script into the exampleProofs
object.
import { deployer, network, ethers } from 'hardhat';
import { DEFAULT_GAS_PER_PUBDATA_LIMIT, getPaymasterParams } from 'zksync-ethers/build/utils';
import { Provider, SmartAccount, type types, Wallet } from 'zksync-ethers';
// Address of the contract to interact with
const GAME_CONTRACT_ADDRESS = process.env.GAME_CONTRACT_ADDRESS ?? '0x...';
const PAYMASTER_CONTRACT_ADDRESS = process.env.PAYMASTER_CONTRACT_ADDRESS ?? '0x...';
const exampleProofs: { publicValues: string; proofBytes: string }[] = [
{
publicValues: '0x...',
proofBytes: '0x...',
},
];
// An example of a script to interact with the contract
async function main() {
console.log(`Running script to interact with contract ${GAME_CONTRACT_ADDRESS}`);
// Load compiled contract info
const contractArtifact = await deployer.loadArtifact('Brickles');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const config: any = network.config;
const provider = new Provider(config.url);
const [deployerWallet] = await ethers.getWallets();
// Initialize contract instance for interaction
const contract = new ethers.Contract(GAME_CONTRACT_ADDRESS, contractArtifact.abi, deployerWallet);
// submit scores
for await (const proofData of exampleProofs) {
console.log('SUBMITTING SCORE....');
await newSubmission(provider, proofData.publicValues, proofData.proofBytes, contractArtifact.abi);
}
// get all the high scores
const topScores = await contract.getTopScores();
console.log('Top scores: ', topScores);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function newSubmission(provider: Provider, publicValues: string, proofBytes: string, abi: any[]) {
const newWallet = Wallet.createRandom().connect(provider);
await sendTx(newWallet.address, newWallet.privateKey, abi, provider, [publicValues, proofBytes]);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function sendTx(address: string, pk: string, abi: any, provider: Provider, args: [string, string]) {
try {
const account = new SmartAccount({ address, secret: pk }, provider);
const rawTx = await getTransaction(
GAME_CONTRACT_ADDRESS,
address,
'0',
new ethers.Interface(abi).encodeFunctionData('verifyProof', args),
provider
);
const tx = await account.sendTransaction(rawTx);
await tx.wait();
} catch (error) {
console.error('Error registering key in account:', error);
}
}
export async function getTransaction(to: string, from: string, value: string, data: string, provider: Provider) {
const gasPrice = await provider.getGasPrice();
const chainId = (await provider.getNetwork()).chainId;
const nonce = await provider.getTransactionCount(from);
const overrides = await getPaymasterOverrides();
return {
to,
from,
value: ethers.parseEther(value),
data,
gasPrice,
gasLimit: BigInt(2000000000),
chainId,
nonce,
type: 113,
customData: overrides.customData as types.Eip712Meta,
};
}
export async function getPaymasterOverrides() {
const paymasterParams = getPaymasterParams(PAYMASTER_CONTRACT_ADDRESS, {
type: 'General',
innerInput: new Uint8Array(),
});
return {
customData: {
gasPerPubdata: DEFAULT_GAS_PER_PUBDATA_LIMIT,
paymasterParams,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Finally, run the interact script to verify the mock proof in the game contract and fetch the top player scores.
npm run interact
The logged result should look something like this:
SUBMITTING SCORE....
Top scores: Result(1) [
Result(4) [
'0xBC989fDe9e54cAd2aB4392Af6dF60f04873A033A',
1027n,
6n,
10350n
]
]
The top scores array should contain one score, and the score itself contains the player address, block timestamp, final score, and recorded time * 1000.
Now that we have the backend for the app working, let's complete the frontend in the next section.