Writing The Program

Build the backend service to generate ZK proofs for your app.

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:

  1. Read the private inputs, which will include the recorded player actions, and the final score and time that they claim they achieved.
  2. Use the inputs to re-run the game program using the verify_replay method in the game library. This method will return true if the claimed final score and time matches what was produced from running the game using the recorded actions, and false if it does not match.
  3. Output the encoded result of verify_replay as well as the final score and time, so it can be decoded inside our Solidity contract. The PublicValuesStruct coming from the game library matches the one inside the Brickles.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:

backend/sp1_program/Cargo.toml
[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:

backend/sp1_program/src/main.rs
// 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.

backend/script/Cargo.toml
[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.

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.

contracts/scripts/interact.ts
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.


Made with ❤️ by the ZKsync Community