Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

This is the documentation for midnight-extractor, a tool that extracts selected Halo2 circuits from Midnight’s codebase into PCL, the language used by Picus for analyzing ZK circuits.

The documentation is divided in two parts, the User manual and the Developer manual.

The User manual contains the instructions for building and using the tool, and a tutorial on how to use AuditHub for uploading extracted circuits and verifying them with Picus.

The Developer manual describes how to write new harnesses and how to add support for new chips and types.

Click on the right arrow to continue through the User manual or you can jump directly to the Developer manual.

Installation

The extraction tool needs to build from source because the harness definitions depend on the circuits crate in midnight-zk. At the time of writing the tool actually depends on the forked version of the crate.

After cloning the repository, verify that the version of midnight-circuits that the tool is linked against is the version you are interesting in extracting from.

Check that the dependency is pointing to the right repository and the right revision. Also make sure that it has the extraction feature enabled. Without this feature the circuits will not include the necessary integrations.

For example:

$ cargo info midnight-circuits
midnight-circuits
version: 5.0.1 (from https://github.com/Veridise/midnight-zk#7042b270)
license: unknown
rust-version: unknown
features:
 +default              = []
  dev-curves           = [midnight-curves/dev-curves, midnight-proofs/dev-curves]
  extraction           = [extractor-support, testing, midnight-proofs/extraction, picus/extractor-derive]
  extractor-support    = [dep:extractor-support]
  heap_profiling       = []
  serde                = [dep:serde]
  serde_derive         = [dep:serde_derive]
  serde_json           = [dep:serde_json]
  testing              = [num-bigint/rand]
  truncated-challenges = [midnight-proofs/truncated-challenges]

After verifying everything looks good you can build the tool with the usual commands. The tool can be found in target/release/midnight-extractor.

cargo build --release
target/release/midnight-extractor --help

Extraction

After building the tool you can use the midnight-extractor CLI to extract the circuits.

Quickstart

To extract all the circuits you can use the extract-all.sh script located in the scripts directory. The Picus representation of the extracted circuits can be found in the picus_files directory.

CLI usage

The midnight-extractor CLI builds a list of circuits at runtime and extracts them into PCL. The CLI has a series of flags and arguments that allows controlling the selection of circuits to extract. The CLI also has flags for controlling the output produced by the tool.

Use the --help flag to see the full list of options.

Circuit selection

Each circuit has 4 properties for filtering and selection; instruction, chip, type, and name.

The instruction groups together circuits that are related and, in general, corresponds to an instruction-like trait (i.e. arithmetic corresponds to ArithInstructions or ecc to EccInstructions). The instructions are selected as positional arguments. Adding instructions to the CLI invocation creates a set of selected instructions. If no instructions are passed to the CLI then all instructions are considered.

The chip declares what concrete implementation of the instruction is used by the circuit. Different chips implement the same instruction traits and this parameter allows configuring what chip is going to be targeted. To select a chip use the --chip <name> flag. This will make the tool filter out any circuit that is not implemented by the chip. If the flag is not passed then the tool will consider all chips. For ignoring chips instead use the --ignore-chips <chip[,chip,...]> flag with a comma separated list of chips to ignore.

The type declares what high-level type the circuit is using and can be selected with the --type <name> flag. Similarly to --chip, not passing this flag makes the tool consider all types. The circuits distinguish by type because some circuits implement the same instructions for multiple types. For example, the native chip implements the equality instructions for the native and bit types.

The name describes the functionality the circuit is trying to represent. In general corresponds to methods in one of the instruction-like traits. Some methods have a variable number of arguments. In cases like that multiple circuits may be created with different combinations of arguments and the name will contain information about the combination (i.e. the circuits hash/hash_1/sha256/byte and hash/hash_10/sha256/byte operate on 1 and 10 input values respectively).

You can filter by name with both a whitelist and a blacklist. If the whitelist is passed, any circuit outside of the list is discarded. And, if the blacklist is passed, then any circuit inside it is discarded. If neither the whitelist nor the blacklist are set then they have no effect and all circuit names will be considered for the extraction. Both lists can be configured as comma separated lists passed as arguments to the --method-whitelist and --method-blacklist flags.

You can combine these flags in any way you want. You can also pass the --list flag to the tool to make it print the selected circuits instead of extracting them. This is useful for debugging a circuit selection that is not producing the desired results.

Constants

Some harnesses require a list of literal values that will be used as compile-time constants, representing off-circuit values in the harness’ input. To pass constants use either the --constants or the --constants-file flags, but they cannot be used at the same time.

--constants expects a comma separated list of literal values. All types expect their decimal representation with the exception of bit with expectes either true or false.

--constants-file expects a path to a file containing lines of comma separated values. These values have the same requirements as the --constants flag.

If a harness requires more constants than supplied extraction will fail.

Output directory structure

The output directory can be selected with the -o flag with picus_files used as a default if the flag is omitted. The tool writes one Picus file per extracted harness. These files are organized hierarchically by instruction, name, chip, and type.

For example, the harnesses for equality in the native chip will produce the following structure.

└── picus_files
    └── equality
        ├── is_equal_to
        │   └── native
        │       ├── bit
        │       │   └── output.picus
        │       └── native
        │           └── output.picus
        └── is_equal_to_fixed
            └── native
                ├── bit
                │   └── output.picus
                └── native
                    └── output.picus

AuditHub

AuditHub is a Veridise service where users can run verification tools such as Picus. Is accesible via both its web UI and CLI. In these sections we will explain how to prepare a project for AuditHub, how to configure the CLI, and how to use it for interacting with AuditHub.

AuditHub CLI

The CLI is a Python 3 program that can be installed with pip.

pip install audithub-client

The CLI needs to be configured before use. To do that go AuditHub’s UI and navigate to Account Settings / API Keys. Follow the wizard to create a new API key and store the given client id and client secret somewhere secure depending on the intended usage of the keys.

API Key generation dialog

For local use that could be a .envrc file managed by direnv. For using them in CI that could be Github Secrets accesible by Github Actions.

Once the keys are ready check the CLI’s documentation on how to configure your system such that the CLI can use the keys to connect to AuditHub.

You can confirm that the CLI is working correctly by running the following

$ ah get-my-profile
{"id": "...", "name": "<Your name>", "email": "<Your email>", "rights": [...], ...}

Creating a new project

Before being able to upload new versions to AuditHub and launch jobs from the CLI it is necessary to create a project from the UI. For the first version of the project generate at least some Picus files with midnight-extractor and pack them into a ZIP file.

# For example, generate some Picus files for the equality instructions
midnight-extractor --chip native --type native equality
zip -r picus_files.zip picus_files

Then go to AuditHub’s UI. Navigate to the organization where you want to create the project and click in Create New Project

Organization dashboard

Give to the project a name and click Next.

Project Name

Select the File option and locate the ZIP file we prepared earlier. Once you upload it click Next.

Source Location

Select Archive root and click Next.

Project Root

In this section, leave the options as they are and click Next.

Project Paths

Select Picus files (and optionally LLZK files) and click Next.

Project Contents

Leave the next two sections untouched and just click Next.

Dependencies

Build

Check that everything looks right and click Submit.

Review

Once the project has been created you will be able to access its main view. For interacting with this project from the CLI you need to pass the organization and project IDs. In the picture below, note the part organizations/<number>/projects/<number> of the URL. The first number is the organization ID (36) and the second the project ID (545).

Project view

These IDs can be configured as environment variables that the CLI will read.

export AUDITHUB_ORGANIZATION_ID=<organization id>
export AUDITHUB_PROJECT_ID=<project id>

Uploading new versions

Make sure you went through the steps in Creating a new project before continuing.

After extracting a new version of the circuits but before running Picus with them, they need to be uploaded to AuditHub. This can be accomplised with either the UI or with the CLI. In this document we are going to see how to do it with the CLI, that you should have configured already.

Each version needs to have a name. As an example we are going to use the date of submission and the sha256 of the generated picus files. Run the following commands, assuming the picus files are in the default output folder (picus_files).

date=$(date "+%Y-%m-%d-%H-%M")
sum=$(find picus_files -type f | sort | xargs cat | sha256sum | head -c 8)
version_id=$(ah create-version-via-local-archive \
    --name "Picus-files-$date-$sum" \
    --source-folder picus_files \
    --project-id $PROJECT_ID \
    --organization-id $ORGANIZATION_ID)

If you configured the AUDITHUB_PROJECT_ID and AUDITHUB_ORGANIZATION_ID environment variables you can drop the --project-id and --organization-id flags since the CLI can read them from the environment. Note that we stored the output of the CLI in a variable named $version_id. We need it for identifying the version when launching jobs.

Launching jobs

Once you have uploaded a version of the extracted circuits to AuditHub you can start running tools with them.

We are going to see how to launch a Picus job from the CLI. Each individual Picus file needs to be run as a separate task. Pick one of the Picus files from a version uploaded to AuditHub and note its path relative to the directory you uploaded it from. This is the path AuditHub will use to locate the file previouly uploaded.

For example, to run a job on one of these files for up to one hour you can run the following command.

# Example path that could be extracted 
PICUS_FILE=arithmetic/add/native/native/output.picus
ah start-picus-v2-task \
    --source $PICUS_FILE \
    --version-id $version_id \
    --project-id $PROJECT_ID \
    --organization-id $ORGANIZATION_ID \
    --time-limit 3600000 \
    --empty-assume-deterministic \
    --wait

This will wait until the job has completed in AuditHub. If you don’t pass the --wait flag the CLI returns immediately with the task ID. This ID can be passed to ah monitor-task -t $task_id for checking the status of the task.

You can run ah start-picus-v2-task --help to get more details about the possible flags and arguments.

Similarly to the upload, if you configured the AUDITHUB_PROJECT_ID and AUDITHUB_ORGANIZATION_ID environment variables you can drop the --project-id and --organization-id flags since the CLI can read them from the environment.

In some cases Picus requires additional configuration; if you passed the --prelude flag while extracting circuits with either spread or automaton then you need to pass additional flags. If you are unsure wether the Picus file you want to analyze was generated using that flag, inspect the first few lines of the file. If the tool injected one of the preludes it will contain comments indicating it.

Pass the additional flags to the CLI according to the table below.

PreludeAdditonal Picus flags
spread--assume-deterministic Spread,Unspread
automaton--assume-deterministic Automaton
None--empty-assume-deterministic

Writing new harnesses

Add new harnesses in the crates/harnesses crate. That crate is organized by instructions and, in some cases, by chips or gadgets that implement the instructions.

Conceptually, a harness is a function that receives a chip, a layouter, some inputs, and returns a Result. In reallity a harness is a function with a more abstract signature, but that complexity is usually managed by a macro that allows for a more declarative approach. However, if the macro is not an option see the section below on how to write harnesses from scratch.

Writing declarative harnesses

A harnesses written declaratively has the following structure.

// The harnesses are NOT parametrized by a field
// but we use an alias for readibility.
use mdnt_extractor_core::fields::Blstrs as F;

// Sets the harness name for the function below.
#[entry("control-flow/select/native/native")]
// Marks the function as a harness
#[harness]
pub fn select_native(
    // The first argument is the chip, a &-reference to a type that implements 
    // `CircuitInitialization`.
    chip: &NativeChip<F>,
    // The second argument is the layouter. The actual layouter variable is not a 
    // `impl Layouter<F>`. This is syntactic sugar for a namesake variable that 
    // implements `midnight_proofs::circuit::Layouter`. So the result is the same.
    layouter: &mut impl Layouter<F>,
    // The third argument are the inputs, whose type must implement `LoadFromCells`.
    (cond, a, b): (AssignedBit<F>, AssignedNative<F>, AssignedNative<F>),
    // Optional fourth argument that allows injecting additional IR for aiding verification.
    injected_ir: &mut InjectedIR<RegionIndex, Expression<F>>
    // The output must be a Result<T, Errot> where T is a type that implements `StoreIntoCells`.
) -> Result<AssignedNative<F>, Error> {
    // Runs the target method.
    chip.select(layouter, &cond, &a, &b)
}

And that’s it. The list of harnesses is defined automatically at compile time and can be obtained by calling the mdnt_harnesses::harnesses function. The extractor uses that function for selecting what harnesses need to be extracted.

The macros do a lot of heavy lifting in converting this form to how the harnesses look internally. The #[entry("...")] macro registers the function in the list of harnesses. This registration is actually accomplised with mdnt_extractor_core::entry! and in some cases is actually better to use this macro instead of #[entry]. For example, when a harness is parametrized by a constant parameter for selecting the size of some arrays.

entry!("bar/example_10/foo/native", example::<10>);
entry!("bar/example_20/foo/native", example::<20>);
#[harness]
pub fn example<const N: usize>(
    chip: &FooChip<F>,
    layouter: &mut impl Layouter<F>,
    arr: [AssignedNative<F>; N]
) -> Result<AssignedNative<F>, Error> {
    chip.foo(layouter, &arr)
}

The harness macro family has 6 macros that can be used for declaring harnesses and offer some flexibility for covering most cases.

For most chips (the ones where CircuitInitialization::Args == ()) use the macros harness, harness_mut, or unit_harness. The first macro is the one we saw above already. harness_mut is similar to harness but the first argument (the chip argument) must be a &mut-reference instead. unit_harness is for methods that don’t have a return value (Result<(), Error>). To use this macro split the inputs of the function into two sets and pass them as the third and fourth argument. The 4th argument can be considered the output and additional constraints can be injected for aiding Picus with verification. That argument, however, is not encoded as a Picus output. Is just a separation for readability. In these cases a vacuous output is generated that is constrained to be equal to 0. If you need to inject IR in a unit_harness you can access the injector via the fifth optional argument.

If the target chip CircuitInitialization::Args != () then you need to use harness_with_args, harness_with_args_mut, or unit_harness_with_args. These macros work identically to their counterparts but require declaring the type of CircuitInitialization::Args (i.e. #[harness_with_args(usize)]). To provide the arguments you need to define a function that has the same name as the harness function followed by _args (i.e. foo would be foo_args). That function cannot take any arguments and return a value of the declared type.

Since the most common argument type is usize we include a convenience macro #[usize_args(<usize>)] that automatically creates the function with the correct name and returns the value passed as argument to the macro.

All the macros accept an optional argument with an expression of type LookupCallbacks that allows defining custom behaviors to handle lookups. For example, a harness like the first one but for NativeGadget instead needs to handle the range lookup the gadget uses. For that we can do as follows:

#[entry("control-flow/select/native-gadget/native")]
// The range_lookup function creates the appropiate handler for the lookup.
#[harness(range_lookup(8))]
pub fn select_native(
    chip: &NG<F>,
    layouter: &mut impl Layouter<F>,
    (cond, a, b): (AssignedBit<F>, AssignedNative<F>, AssignedNative<F>),
) -> Result<AssignedNative<F>, Error> {
    chip.select(layouter, &cond, &a, &b)
}

Writing harnesses from scratch

If the macros shown above do not fit the needs of a new harness they can still be defined by hand. Below is an annotated version of the kind of code the macros generate that can serve as a starting point for creating a harness from scratch.

entry!("control-flow/select/native-gadget/native", select_native);
fn select_native(ctx: &Ctx) -> anyhow::Result<Output> {
    // The actual logic of the harness needs to be wrapped into a trait.
    // So we need to create a type for that.
    struct Circuit<'s, 'c>(PhantomData<(&'s (), &'c ())>);

    // This trait defines the types that the harness uses. 
    // The extractor will rely on this trait for finding the right types for 
    // creating the right final circuit.
    impl<'s, 'c> AbstractCircuitIO for Circuit<'s, 'c> {
        // The chip type. It must implement CircuitInitialization but is not enforced.
        type Chip = NativeChip<F>;

        // The type of the inputs. Must implement `LoadFromCells` and if the method takes several 
        // they can be grouped in tuples up to 12 elements.
        type Input = (AssignedBit<F>, AssignedNative<F>, AssignedNative<F>);

        // The type of the outputs. Same as the inputs but must implement the 
        // `StoreIntoCells` trait instead.
        type Output = AssignedNative<F>;

        // These two types need to be the same as their namesakes in CircuitInitialization.
        type Config = <NativeChip<F> as CircuitInitialization<ExtractionLayouter<'s, 'c, F>>>::Config;

        type ConfigCols =
            <NativeChip<F> as CircuitInitialization<ExtractionLayouter<'s, 'c, F>>>::ConfigCols;
    }

    // The actual logic is defined with this trait.
    // `harness_mut` will use `AbstractCircuitMut`
    impl AbstractCircuit<F> for Circuit<'_, '_> {
        fn synthesize<L>(
            &self,
            chip: &Self::Chip,
            layouter: &mut L,
            (cond, a, b): Self::Input,
            _: &mut InjectedIR<
                RegionIndex,
                Expression<F>,
            >,
        ) -> anyhow::Result<Self::Output, Error>
        where
            L: Layouter<F>,
        {
            chip.select(layouter, &cond, &a, &b)
        }
    }

    // If the chip arguments is () then this trait can be used.
    impl NoChipArgs for Circuit<'_, '_> {}

    // The type we created and implemented the types for can be passed to this 
    // type. This type handles the linking between the inputs, outputs, and the harness logic.
    let ci: CircuitImpl<'_, F, Circuit, Function> =
        CircuitImpl::new(ctx, Circuit(Default::default()));
    // The first argument of this method is a type that implements the  
    // `CircuitSynthesis` trait, which is Haloumi's counterpart to the Circuit trait in Halo2.
    // The types and traits above handle most of the boilerplate and help keep the harnesses more readable.
    // However, the only trait the frontend needs is `CircuitSynthesis` so other approaches are possible so long 
    // they implement that trait.
    // 
    // The second argument is an optional &dyn-reference to a LookupCallbacks implementation.
    // These callbacks are invoked when the circuit has lookups for getting the IR that needs to be 
    // generated for handling the lookup.
    ctx.lower_circuit(ci, None)
}

Supporting new chips

In order to use user defined chips or gadgets in a harness it is necessary to implement a trait that makes it compatible with the extraction system.

Let’s do a small refresher on why this is necessary. A harness can be defined as follows.

#[entry("control-flow/select/native/native")]
#[harness]
pub fn select_native(
    chip: &NativeChip<F>,
    layouter: &mut impl Layouter<F>,
    (cond, a, b): (AssignedBit<F>, AssignedNative<F>, AssignedNative<F>),
) -> Result<AssignedNative<F>, Error> {
    chip.select(layouter, &cond, &a, &b)
}

This harness targets the NativeChip. When the extractor attempts to extract the harness it needs to know some things about this chip, but it doesn’t know what the NativeChip is. This information is obtained via the CircuitInitialization trait. This trait, similar in nature to Halo2’s Circuit trait, enables the extractor to configure and create chips that implement it.

Let’s consider the following chip as an example.

struct FooConfig {
    advice: [Column<Advice>; ADVICE_WIDTH],
    fixed: [Column<Fixed>; FIXED_WIDTH],
    table: [TableColumn; TABLE_WIDTH]
}

struct FooChip<F> {
    config: FooConfig,
    native: NativeChip<F>
}

impl<F: PrimeField> FooChip<F> {
    fn configure(meta: &mut ConstraintSystem<F>) -> FooConfig {
        // Create an instance of the config... 
    }

    fn new(config: FooConfig, native: NativeChip<F>) -> Self {
        Self { config, native }
    }

    fn load_table(&self, layouter: impl Layouter<F>) -> Result<(), Error> {
        // Loads the lookup table...
    }
}

The example chip has a lookup table and depends on NativeChip, which already implements the trait we are going to implement. The trait is parametrized in L, that we can use as an implementation of a Layouter.

impl<F, L> CircuitInitialization<L> for FooChip<F>
where
    F: PrimeField,
    L: Layouter<F>,
{
    // Type that is generated from the ConstraintSystem, usually the Chip's config.
    type Config = (FooConfig, <NativeChip<F> as CircuitInitialization<F>>::Config);

    // Any external arguments the chip may need. If the chip does not have any then 
    // an empty tuple is sufficient.
    type Args = ();

    // Any type that is part of the configuration that could be potentially shared with other chips.
    // FooChip does not have any of that in this case so we just put NativeChip's.
    type ConfigCols = <NativeChip<F> as CircuitInitialization<F>>::ConfigCols;

    // These two types are required because mdnt-support does not have a dependency on any 
    // halo2 library. We need to declare what types are used as ConstraintSystem and as Error.
    // Usually these will be the types `<halo2-crate>::plonk::{ConstraintSystem, Error}`.
    type CS = ConstraintSystem<F>;
    type Error = Error;

    /// Initializes a new chip with the given config and arguments, if any.
    fn new_chip((config, native_config): &Self::Config, _: Self::Args) -> Self {
        Self::new(config, NativeChip::new_chip(native_config, ()))
    }

    /// Creates a new configuration.
    fn configure_circuit(
        meta: &mut Self::CS,
        config_cols: &Self::ConfigCols,
    ) -> Self::Config {
        (Self::configure(meta), NativeChip::configure_circuit(meta, config_cols))
    }

    /// Performs any required loading, such as lookup tables.
    /// This method is called by the extractor after the harness' method has been executed.
    fn load_chip(&self, layouter: &mut L, (_, native_config): &Self::Config) -> Result<(), Self::Error> {
        self.load_table(layouter)?;
        self.native.load_chip(layouter, native_config)
    }
}

After implementing this trait the type can be used as the chip argument in any harness.

Supporting new assigned types

In order to use user defined types as inputs or outputs in a harness it is necessary to implement traits that make the type compatible with the extraction system.

Let’s do a small refresher on why this is necessary. A harness can be defined as follows.

#[entry("control-flow/select/native/native")]
#[harness]
pub fn select_native(
    chip: &NativeChip<F>,
    layouter: &mut impl Layouter<F>,
    (cond, a, b): (AssignedBit<F>, AssignedNative<F>, AssignedNative<F>),
) -> Result<AssignedNative<F>, Error> {
    chip.select(layouter, &cond, &a, &b)
}

In this example the input has type (AssignedBit<F>, AssignedNative<F>, AssignedNative<F>) and the output has type AssignedNative<F>. When lowering to Picus we cannot use these types directly because PCL does not support user defined types and everything is a field element. The extractor works around this issue by creating two auxiliary tables (one for inputs and another for outputs) that represent the inputs and outputs of the circuit as individual cells. In the example above we need 3 cells in the input auxiliary table and 1 cells in the output auxiliary table. This is because AssignedNative<F> is an alias over Halo2’s AssignedCell<F,F>, which takes up 1 cell, and AssignedBit<F> wraps an AssignedNative<F>.

As an example, we are going to add support to the following type as we explain the concepts.

struct AssignedFoo<F> {
    foo: [AssignedNative<F>; 10],
    bar: AssignedBit<F>
}

The extractor knows how many cells a type occupies using the CellReprSize trait from the mdnt-support crate. This trait has a constant usize that declares the amount of cells required. Types such as AssignedCell<F,F> or [T;N] already implement this trait, so we can leverage that for implementing the trait for AssignedFoo.

// Showcase of the implementation for AssignedCell.
impl<V, F> CellReprSize for AssignedCell<V, F> {
    const SIZE: usize = 1;
}

// For our type we can leverage the existing implementations.
impl<F> CellReprSize for AssignedFoo<F> {
    const SIZE: usize = <[AssignedNative<F>; 10]>::SIZE + <AssignedBit<F>>::SIZE; // Total 11 cells.
}

Now that we have the trait that declares the size of the type we can add support for reading, writing, or both. For using the type as an input in a harness we need to implement LoadFromCells. And for using the type as an output we need to implement StoreIntoCells. Both traits have 4 type parameters: F, C, E, and L. F is the type of the field and needs to implement the ff::PrimeField trait. C is the chip we are using for the harness. In the example at the beginning of this chapter it would be NativeChip. E is the extraction adaptor, which is a type that has a set of associated types representing different Halo2 concepts. E must implement the Halo2Types trait in mdnt-support. Last, L does not necessarily need to implement any trait in order to satisfy either LoadFromCells or StoreIntoCells. However, some implementations may require an instance of a Layouter and L can be constrained to that trait (L: Layouter<F>).

impl<F, L, C, E> LoadFromCells<F, C, E, L> for AssignedFoo<F>
where 
    F: PrimeField,
    E: Halo2Types<F>
{
    fn load(
        ctx: &mut ICtx<F, E>,
        chip: &C,
        layouter: &mut impl LayoutAdaptor<F, E, Adaptee = L>,
        injected_ir: &mut InjectedIR<E::RegionIndex, E::Expression>,
    ) -> Result<Self, E::Error> {
        // Inside this method we need to construct an instance of the type.
        Ok(Self {
            // We can leverage the existing implementations of the trait.
            foo: ctx.load(chip, layouter, injected_ir)?,
            bar: ctx.load(chip, layouter, injected_ir)?,
        })
    }
}

impl<F, L, C, E> StoreIntoCells<F, C, E, L> for AssignedFoo<F>
where 
    F: PrimeField,
    E: Halo2Types<F>
{
    fn store(
        self,
        ctx: &mut OCtx<F, E>,
        chip: &C,
        layouter: &mut impl LayoutAdaptor<F, E, Adaptee = L>,
        injected_ir: &mut InjectedIR<E::RegionIndex, E::Expression>,
    ) -> Result<(), E::Error> {
        // Inside this method we need to convert the type into a set of cells.
        // Again, we can leverage existing implementations.
        self.foo.store(ctx, chip, layouter, injected_ir)?;
        self.bar.store(ctx, chip, layouter, injected_ir)
    }
}

Inside those traits the programmer has the freedom to perform the conversion in different ways. The ctx gives access to the raw cells. The chip and layouter allow performing the conversion via some method the chip implements. For example, because the target chip implements a trait that already has the desired logic (adding C: ThatTrait to the where bounds). injected_ir allows writing Haloumi IR encoding semantic properties of the type. For example, the implementations for AssignedBit and AssignedByte in midnight-circuits emit IR that constraints the value to be within the range of the types; 0-1 and 0-255 respetively.

These traits require that the type has a known size and won’t work well with types that have dynamically sized types such as Vec. To work around this limitation we can use the following pattern.

/// Dynamically sized version of AssignedFoo
struct AssignedFooDyn<F> {
    foo: Vec<AssignedNative<F>>,
    bar: AssignedBit<F>
}

/// We create a new type that has a known length.
/// We will implement the traits on this type and use it as input and/or output.
/// Inside the harness we then convert between this type and [`AssignedFooDyn`].
struct LoadedFooDyn<F, const N: usize> {
    foo: AssignedFooDyn<F>,
    _marker: PhantomData<[(); N]>
}

impl<F, const N: usize> From<LoadedFooDyn<F, N>> for AssignedFooDyn<F> {
    fn from(value: LoadedFooDyn<F, N>) -> Self {
        value.foo
    }
}

// And implement the traits on this new type wrapper. 

impl<F, const N: usize> CellReprSize for LoadedFooDyn<F, N> {
    const SIZE: usize = <AssignedNative<F>>::SIZE * N + <AssignedBit<F>>::SIZE; // Total N+1 cells.
}

impl<F, L, C, E, const N: usize> LoadFromCells<F, C, E, L> for LoadedFooDyn<F, N>
where 
    F: PrimeField,
    E: Halo2Types<F>
{
    fn load(
        ctx: &mut ICtx<F, E>,
        chip: &C,
        layouter: &mut impl LayoutAdaptor<F, E, Adaptee = L>,
        injected_ir: &mut InjectedIR<E::RegionIndex, E::Expression>,
    ) -> Result<Self, E::Error> {
        let foo_arr: [AssignedNative<F>; N] = ctx.load(chip, layouter, injected_ir)?;
        Ok(Self {
            foo: AssignedFooDyn {
                foo: foo_arr.to_vec(),
                bar: ctx.load(chip, layouter, injected_ir)?,
            },
            _marker: PhantomData
        })
    }
}

impl<F, L, C, E, const N: usize> StoreIntoCells<F, C, E, L> for LoadedFooDyn<F, N>
where 
    F: PrimeField,
    E: Halo2Types<F>
{
    fn store(
        self,
        ctx: &mut OCtx<F, E>,
        chip: &C,
        layouter: &mut impl LayoutAdaptor<F, E, Adaptee = L>,
        injected_ir: &mut InjectedIR<E::RegionIndex, E::Expression>,
    ) -> Result<(), E::Error> {
        let foo_arr: [AssignedNative<F>; N] = self.foo.foo.try_into().map_err(|_| {
            // Handle error...
        })?;
        foo_arr.store(ctx, chip, layouter, injected_ir)?;
        self.foo.bar.store(ctx, chip, layouter, injected_ir)
    }
}