Quickstart

This guide will help you set up a basic example that folds a circuit that describes the identify function F(X)=XF(X) = X. The entire code can be found at https://github.com/snarkify/sirius-quickstart.

Step 1: Create a New Rust Project

  1. Install rust with rustup

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
  1. Create a new project with cargo

cargo new my-project --bin;
cd my-project;
  1. Add 'sirius' to dependencies

cargo add --git https://github.com/snarkify/sirius/ --tag v0.1.1;

Step 2: Implement `MyStepCircuit`

Full code can be found at https://github.com/snarkify/sirius-quickstart/blob/main/src/main.rs

Let's break down each section of this code

Import Necessary Modules

First, import the necessary modules and types from the Sirius library:

use std::path::Path;

use sirius::{
    ff::Field,
    ivc::{
        step_circuit::{trivial, AssignedCell, ConstraintSystem, Layouter},
        SynthesisError,
    },
    prelude::{
        bn256::{new_default_pp, C1Affine, C1Scalar, C2Affine, C2Scalar},
        CommitmentKey, PrimeField, StepCircuit, IVC,
    },
};

This block imports modules for handling fields, circuits, and the incremental verification process. It also sets up the types for the BN256 and Grumpkin elliptic curves.

Define Constants and Circuit Structure

Define constants and the structure for your custom circuit:

/// Number of folding steps
const FOLD_STEP_COUNT: usize = 5;

// === PRIMARY ===

/// Arity: Input/output size per fold-step for the primary step-circuit
const A1: usize = 1;

/// Initial input for the primary circuit
const PRIMARY_Z_0: [C1Scalar; A1] = [C1Scalar::ZERO];

/// Key size for the Primary Circuit
const PRIMARY_COMMITMENT_KEY_SIZE: usize = 19;

/// Table size for the Primary Circuit
const PRIMARY_CIRCUIT_TABLE_SIZE: usize = 17;

// === SECONDARY ===

/// Arity: Input/output size per fold-step for the secondary step-circuit
const A2: usize = 1;

/// Initial input for the secondary circuit
const SECONDARY_Z_0: [C2Scalar; A1] = [C2Scalar::ZERO];

/// Table size for the Secondary Circuit
const SECONDARY_CIRCUIT_TABLE_SIZE: usize = 17;

/// Key size for the Secondary Circuit
const SECONDARY_COMMITMENT_KEY_SIZE: usize = 19;

Explanation:

  • Folding Steps (FOLD_STEP_COUNT): Specifies the number of steps the IVC process will execute. Each step represents a round of computation in the circuit.

  • Arity (A1 and A2): Defines the input/output size per step for the primary and secondary circuits. Set to 1 in this example, meaning one input and one output per step.

  • Initial Inputs (PRIMARY_Z_0 and SECONDARY_Z_0): These are the starting values passed to the circuits at the zero step. Here, they are initialized to zero (C1Scalar::ZERO and C2Scalar::ZERO), but in a real circuit, you should replace these with the actual values required by your circuit.

  • Commitment Key Size (PRIMARY_COMMITMENT_KEY_SIZE and SECONDARY_COMMITMENT_KEY_SIZE): Defines the size of the commitment keys for the circuits. Start with the provided values, but increase if your circuit requires more.

  • Table Size (PRIMARY_CIRCUIT_TABLE_SIZE and SECONDARY_CIRCUIT_TABLE_SIZE): Specifies the lookup table sizes. A minimum of 17 is needed, but increase the size if your circuit demands more complex operations.

Implement the StepCircuit Trait

Next, define a basic implementation of the StepCircuit trait:

/// This structure is a template for configuring your circuit
///
/// It should store information about your PLONKish structure
#[derive(Debug, Clone)]
struct MyConfig {}

/// This page is a template for your circuit
/// Within this code - it returns the input unchanged
struct MyStepCircuit {}

impl<const A: usize, F: PrimeField> StepCircuit<A, F> for MyStepCircuit {
    /// This is a configuration object that stores things like columns.
    type Config = MyConfig;

    /// Configure the step circuit. This method initializes necessary
    /// fixed columns and advice columns.
    fn configure(_cs: &mut ConstraintSystem<F>) -> Self::Config {
        MyConfig {}
    }

    /// Sythesize the circuit for a computation step and return variable
    /// that corresponds to the output of the step z_{i+1}
    /// this method will be called when we synthesize the IVC_Circuit
    ///
    /// Return `z_out` result
    fn synthesize_step(
        &self,
        _config: Self::Config,
        _layouter: &mut impl Layouter<F>,
        z_i: &[AssignedCell<F, F>; A],
    ) -> Result<[AssignedCell<F, F>; A], SynthesisError> {
        // For this example we do not modify anything, we return the input unchanged
        Ok(z_i.clone())
    }
}

In this section, the StepCircuit trait is implemented for the MyStepCircuit struct:

  • struct MyConfig Acts as a template for configuring your circuit. Although it's empty in this example, it usually stores important constrain system configuration details.

  • struct MyStepCircuit Represents the actual step-circuit logic. In this example, it simply returns the input unchanged, making it a trivial circuit. This is useful as a starting point or for testing purposes, but real circuits would involve more complex operations.

  • fn configureThis method is where the circuit's configuration is set up. It initializes any necessary columns & gates & lookups in the constraint system. The method currently returns an empty configuration, but in a more advanced circuit, it would be populated with the relevant data.

  • fn synthesize_stepThis is the core of the circuit, where the computation for each step happens. It takes the configuration and an array of input variables (z_i), and returns the next step's output. In this trivial example, the output is simply the input, but this is where you would implement the actual logic for your step-circuit's step function.

This example serves as a basic template. When developing your own circuits, you would replace the trivial operations with the actual logic needed for your application.

Check `StepCircuit` trait for more details

Set Up Commitment Keys

Next, we need to set up the commitment keys for our circuits. This involves generating or loading keys for the elliptic curves used in the primary and secondary circuits.

// This folder will store the commitment key so that we don't have to generate it every time.
//
// NOTE: since the key files are not serialized, but reflected directly from memory, the
// functions to load them is `unsafe`
let key_cache = Path::new(".cache");
                                                                                              
println!("start setup primary commitment key: bn256");
                                                                                              
// Safety: because the cache file is correct
let primary_commitment_key = unsafe {
    CommitmentKey::<C1Affine>::load_or_setup_cache(
        key_cache,
        "bn256",
        PRIMARY_COMMITMENT_KEY_SIZE,
    )
    .unwrap()
};
                                                                                              
println!("start setup secondary commitment key: grumpkin");
                                                                                              
// Safety: because the cache file is correct
let secondary_commitment_key = unsafe {
    CommitmentKey::<C2Affine>::load_or_setup_cache(
        key_cache,
        "grumpkin",
        SECONDARY_COMMITMENT_KEY_SIZE,
    )
    .unwrap()
};

Explanation:

  • Storing Generated Keys: The commitment keys are generated and stored in the .cache folder. This is done to avoid the need to regenerate the keys every time you run the program, which saves time and computational resources.

  • First Key Generation: The first time the keys are generated, it can take a significant amount of time. This is because key generation for cryptographic curves involves complex computations. Once generated, these keys are cached for future use.

  • Elliptic Curves: In this example, keys are generated for two different elliptic curves: bn256 for the primary circuit and grumpkin for the secondary circuit.

  • Use of unsafe: The unsafe block is necessary because the functions that load or generate the keys operate directly on memory-mapped files. This approach is used for performance reasons - mapping keys directly from memory is faster than serializing and deserializing them. However, it also requires careful handling to ensure the integrity of the cached files.

This setup is crucial for ensuring that your circuits can efficiently use these cryptographic keys without incurring unnecessary delays in key generation each time the program is executed.

Initialize PublicParams

let pp = new_default_pp::<A1, _, A2, _>(
        SECONDARY_CIRCUIT_TABLE_SIZE as u32,
        &primary_commitment_key,
        &sc1,
        PRIMARY_CIRCUIT_TABLE_SIZE as u32,
        &secondary_commitment_key,
        &sc2,
    );

Explanation:

  • new_default_pp: This function initializes the PublicParams with mostly default settings. It simplifies the setup process by automatically configuring the majority of parameters. However, if your circuit requires specific customizations or more advanced settings, you should create the PublicParams manually, specifying types and values directly.

  • PublicParams: This structure not only store essential configuration data but also lock in the structure of the PLONKish constraint system for the entire IVC process. When you create pp, the configure and synthesize methods of your circuits are called internally to collect and organize the constraints, ensuring consistency across all folding steps in the IVC.

Check `PublicParams` Struct for more details

Perform Folding Steps and Verification

Finally, perform the folding steps and verify the computation:

let mut ivc = IVC::new(&pp, &sc1, PRIMARY_Z_0, &sc2, SECONDARY_Z_0, true)
    .expect("failed to create `IVC`");
println!("ivc created");
                                                                          
for step in 1..FOLD_STEP_COUNT {
    // you can modify circuit data here
    ivc.fold_step(&pp, &sc1, &sc2)
        .expect("failed to run fold step");
                                                                          
    println!("folding step {step} was successful");
}
                                                                          
ivc.verify(&pp).expect("failed to verify ivc");
println!("verification successful");

Explanation:

  • IVC::new: This function initializes the Incrementally Verifiable Computation (IVC) instance and automatically performs the zero step using the provided initial inputs (PRIMARY_Z_0 and SECONDARY_Z_0). This zero step sets up the initial state for the folding process, ensuring that the IVC begins with the correct starting conditions.

  • IVC::fold_step: During each call to fold_step, the circuit's configure and synthesize methods are invoked few times. These methods define and execute the circuit's constraints and logic for that step. In a real-world circuit, you should update the circuit's witness (private inputs) between steps to reflect the changing state of the computation. This ensures that each folding step processes the correct data as the IVC progresses.

  • 'DebugMode': The debug_mode argument (set to true in this example) enables additional checks at each folding step. Specifically, it invokes the MockProver from the Halo2 library to verify the correctness of the computation at each step. While this mode is invaluable for debugging and ensuring correctness during development, it introduces a performance overhead. If you disable debug_mode for performance reasons (by setting it to false), errors will only be detected at the end of the entire computation during the call to IVC::verify.

  • IVC::verify: The verify method is called at the end of the IVC process to check the validity of the entire computation. However, it only reports whether the computation was successful or if an error occurred at some point during the process. If debug_mode is disabled, this method lacks the detailed information to pinpoint where the error happened—only whether the final result is valid or not.

Check `IVC` struct for more details

Step 3: Run

Running the Example

1. First Run

To run the example for the first time, use the following command:

cargo run --release

This will compile the project in release mode, which is optimized for speed. During this initial run, the Set Up Commitment Keys for the BN256 and Grumpkin curves will be generated and cached. This process may take some time, so running in release mode ensures it completes as quickly as possible.

2. Subsequent Runs

For subsequent runs, you can use the following command without the --release flag:

cargo run

This will reuse the previously generated commitment keys, so the process will be faster, and there’s no need to recompile in release mode unless you're making significant changes or need the performance optimization again.

3. Expected Output

When the example runs successfully, you should see output indicating that the folding steps were executed and verified successfully:

start setup primary commitment key: bn256
start setup secondary commitment key: grumpkin
ivc created
folding step 1 was successful
folding step 2 was successful
folding step 3 was successful
folding step 4 was successful
folding step 5 was successful
verification successful
success

Summary

In this Quickstart, we explored setting up a basic example with Sirius IVC, including initializing with PublicParams, performing folding steps, and understanding the role of debug mode and verification. We implemented a trivial step circuit that returns its input unchanged, laying the groundwork for more complex circuits.

For a deeper dive into implementing a real step circuit with dynamic witness management, check out the Fold a Summation Circuit section.

Last updated