Introduction

On March 31st, 2025, zkSecurity was engaged to audit additional Midnight circuits from Midnight’s standard library. The specific codebase was shared via a private GitHub repository (https://github.com/midnightntwrk/midnight-zk) at commit 6e1454ef5c5204bd4705a90b3353daabd5f87428; this code originally resided in an internal repository and was archived in the new repository prior to publication for transparency and future reference. The audit was conducted between March 31st and April 11th, 2025, by two consultants. Overall, the code was found to be clean, well-organized, and thoughtfully structured. Several observations and findings were reported to the Midnight team, which are detailed in the following sections.

Scope

The following files/directories were the scope of the audit:

Overview

This section provides an overview of the Midnight library and circuits that were within the scope of this audit.

Compact Standard Library (compact_std_lib)

The compact_std_lib.rs module serves as the main entry point for the Midnight library. It fulfills several critical roles: configuring chips and gadgets, providing public helper functions for circuit operations (e.g., assign), and exposing a public API for composing circuits, setting them up, producing proofs, and verifying proofs. Below, we briefly explain these functionalities.

ZkStdLibArch defines a configuration for which chips need to be activated in a specific circuit. It contains boolean flags (or an option for SHA256) for enabling jubjub, poseidon, sha256, secp256k1, bls12_381, and base64. In addition, it provides to_byte and from_byte functions for serialization and deserialization of the configuration.

ZkStdLib includes all available chips and gadgets and exposes the ones enabled by the configuration. The configure function of ZkStdLib sets up all the selected chips and handles the logic for sharing advice and fixed columns across chips.

Beyond chip configuration, ZkStdLib also provides helper functions that users commonly need in their circuits. For example, it exposes functions to return the configured chips, as well as wrapped helper methods such as assert_true and assign. Specifically, it implements:

  • assert_true
  • assert_false
  • lower_than
  • poseidon
  • hash_to_curve
  • sha256

It also implements instructions for the following traits:

  • AssignmentInstructions
  • PublicInputInstructions
  • AssertionInstructions
  • EqualityInstructions
  • ConversionInstructions
  • CanonicityInstructions
  • DecompositionInstructions
  • ArithInstructions
  • ControlFlowInstructions
  • FieldInstructions
  • RangeCheckInstructions
  • BinaryInstructions

Midnight Circuit Structure

The Midnight circuit structure is defined as follows:

/// Circuit structure which is used to create any circuit that can be compiled
/// into keys using the ZK standard library.
#[derive(Clone, Debug)]
pub struct MidnightCircuit<'a, R: Relation> {
    relation: &'a R,
    instance: Value<R::Instance>,
    witness: Value<R::Witness>,
    nb_public_inputs: Rc<RefCell<Option<usize>>>,
}

The Relation trait simplifies circuit development:

pub trait Relation: Clone {
    type Instance: Clone;    // Public inputs
    type Witness: Clone;     // Private inputs

    // Convert instance to field elements
    fn format_instance(instance: &Self::Instance) -> Vec<F>;

    // Define circuit logic
    fn circuit(
        &self,
        std_lib: &ZkStdLib,
        layouter: &mut impl Layouter<F>,
        instance: Value<Self::Instance>,
        witness: Value<Self::Witness>,
    ) -> Result<(), Error>;
}

Example: Using the Compact Standard Library

Here’s a complete example demonstrating how to create and use a circuit with the Compact Standard Library:

#[derive(Clone, Default)]
pub struct PoseidonExample;

impl Relation for PoseidonExample {
    type Instance = F;           // Public input: hash output
    type Witness = [F; 3];       // Private input: message to hash

    fn format_instance(instance: &Self::Instance) -> Vec<F> {
        vec![*instance]
    }

    // Circuit implementation
    fn circuit(
        &self,
        std_lib: &ZkStdLib,
        layouter: &mut impl Layouter<F>,
        _instance: Value<Self::Instance>,
        witness: Value<Self::Witness>,
    ) -> Result<(), Error> {
        // Assign witness values
        let assigned_message = std_lib.assign_many(layouter, &witness.transpose_array())?;

        // Compute Poseidon hash
        let output = std_lib.poseidon(layouter, &assigned_message)?;

        // Mark output as public input
        std_lib.constrain_as_public_input(layouter, &output)
    }

    // Configure required chips
    fn used_chips(&self) -> ZkStdLibArch {
        ZkStdLibArch {
            jubjub: false,
            poseidon: true,     // Only enable Poseidon
            sha256: None,
            secp256k1: false,
            bls12_381: false,
            base64: false,
        }
    }
}

// Usage example
fn main() {
    const K: u32 = 10;
    let srs = filecoin_srs(K);

    // Setup
    let relation = PoseidonExample;
    let vk = compact_std_lib::setup_vk(&srs, &relation);
    let pk = compact_std_lib::setup_pk(&relation, &vk);

    // Generate proof
    let mut rng = ChaCha8Rng::from_entropy();
    let witness: [F; 3] = core::array::from_fn(|_| F::random(&mut rng));
    let instance = <PoseidonChip<F> as HashCPU<F, F>>::hash(&witness);

    let proof = compact_std_lib::prove(&srs, &pk, &relation, &instance, witness, OsRng)
        .expect(\"Proof generation should not fail\");

    // Verify proof
    assert!(compact_std_lib::verify::<PoseidonExample>(
        &srs.verifier_params(),
        &vk,
        &instance,
        &proof
    ).is_ok())
}

Parser and Base64 (json)

In the json directory, there are implementations for parsing consecutive bytes and for Base64 decoding. The following sections describe how each of these components is implemented.

Parser Gadget Implementation Overview

The ParserGadget provides core functionality for fetching sequences of native elements and byte arrays. It offers two main functions:

  1. get_subsequence
  • Extracts a subsequence of native field elements.
  • Keeps the index private (as long as the result also remains private).
fn get_subsequence(
    sequence: &[AssignedNative<F>],
    idx: &AssignedNative<F>,
    len: usize
) -> Vec<AssignedNative<F>>
  1. fetch_bytes
  • Higher-level byte sequence extraction.
  • Uses chunking optimization for efficiency.
  • Handles byte-level operations.
pub fn fetch_bytes(
    &self,
    layouter: &mut impl Layouter<F>,
    sequence: &[AssignedByte<F>],
    idx: &AssignedNative<F>,
    len: usize,
) -> Result<Vec<AssignedByte<F>>, Error>

The implementation of get_subsequence first checks that idx is less than sequence.len() - len to ensure a valid range and prevent overflows. It then creates a vector of AssignedCells of size len, assigning and constraining values from the appropriate sequence elements using the native.select function.

To efficiently implement fetch_bytes, the code first splits the bytes into field-sized groups (nb_bytes_per_chunk = F::CAPACITY / 8) and uses two-level indexing:

  • chunk_idx = idx / nb_bytes_per_chunk
  • fine_search_idx = idx % nb_bytes_per_chunk

The algorithm follows this structure:

Input Bytes -> Chunks -> Selected Chunks -> Output Bytes
[b0, b1, b2, ...] -> [chunk0, chunk1, ...] -> [selected] -> [output bytes]

Initially, input bytes are grouped into chunks of size nb_bytes_per_chunk. Each chunk is then converted into a field element using assigned_from_le_bytes. A dummy chunk is added at the end to handle cross-chunk boundary cases. The index is decomposed and constrained via:

idx = chunk_idx * nb_bytes_per_chunk + fine_search_idx

Additionally, fine_search_idx is range-checked to ensure it lies within [0, nb_bytes_per_chunk). The number of required chunks is computed as:

len_for_chunks = min(nb_chunks, 1 + len.div_ceil(nb_bytes_per_chunk))

Selected chunks are extracted using get_subsequence, with the dummy chunk ensuring correct behavior at boundaries. Note that the chunk_idx is range-checked through the get_subsequence function call. The selected chunks are then decomposed back into bytes using assigned_to_le_bytes, and the final bytes are selected via another get_subsequence using fine_search_idx.

Base64 Chip Implementation Overview

The Base64 chip implements Base64 decoding, supporting both standard Base64 and Base64URL encodings. The implementation uses a lookup table that maps pairs of ASCII characters to their Base64-encoded values. The table optimizes lookups by grouping two characters at a time:

t_char (16-bit)    t_val (12-bit)
0x4141 ('AA')      0x0000 (0,0)
0x4142 ('AB')      0x0001 (0,1)
...
0x2F2F ('//')      0x0FFF (63,63)

The core function of the chip is base64_to_val_chunk. This function receives 4 characters (AssignedByte) and returns 4 decoded AssignedNative values. The circuit uses four advice columns organized as:

lookup_sel col_1 col_2 col_3 col_4
1 A B 0x01 0x01
1 C D 0x02 0x03

In base64_to_val_chunk, the input bytes are assigned into the first two advice columns (col_1 and col_2). They are decoded and assigned into the next two columns (col_3 and col_4), and the lookup_sel selector is enabled for both rows. The Base64 lookup gate:

  • Combines columns as (col_1 << 8) | col_2 and (col_3 << 6) | col_4.
  • Performs a lookup in the precomputed table to validate that the pair exists.

This ensures that the combined 12-bit decoded value corresponds to the input 16-bit ASCII pair. However, it does not enforce that col_3 individually decodes col_1 or col_4 decodes col_2 independently (see finding “Incorrect Range Assumptions in Base64 Decoding”). Nevertheless, in the current implementation, no soundness error arises, as the decoded values are immediately processed via val_to_ascii_chunk:

let terms = vec![
    (F::from(1u64 << (3 * 6)), b64_input[0].clone()),
    (F::from(1u64 << (2 * 6)), b64_input[1].clone()),
    (F::from(1u64 << 6), b64_input[2].clone()),
    (F::ONE, b64_input[3].clone()),
];

The chip also implements the following helper functions:

  • url_to_standard: Converts Base64URL encoding to standard Base64 by replacing '-' with '+' and '_' with '/' using selection gadgets.
  • pad: Fills an incomplete chunk with ALT_PAD (‘A’) characters to ensure a 4-byte chunk.
  • process_padded_chunk: Processes Base64 chunks containing '=' padding characters by replacing them with ALT_PAD for proper decoding.
  • from_base64: Decodes a Base64 string into ASCII output, handling chunking and padding.
  • from_base64url: Converts Base64URL strings to standard Base64, then decodes them via from_base64.

Poseidon (Optimized Implementation)

The Midnight implementation of Poseidon has been updated to incorporate a major optimization inspired by ePrint 2022/462. For a thorough explanation of the older implementation and how the Poseidon chip is constructed and can be used, we refer the interested reader to our previous report. In this section, we are going to describe the full round and mainly the partial rounds implementation that have been optimized. In summary, the optimization process multiple partial rounds in a single batch that results in a single row in the plonkish table. This is achieved by:

  • Shifted Rounds: A change in round structure to simplify constraint expressions.
  • Round Skipping: A technique that combines multiple partial rounds into a single constraint.

These changes significantly reduce the number of constraints in the circuit while preserving the security guarantees of the original Poseidon construction.

Shifted Rounds: Simplified Constraint Structure

The original Poseidon permutation applies the round constants before the S-box and MDS multiplication:

state = input 
round 0: state = MDS * Sbox(state + RC[0]) 
round 1: state = MDS * Sbox(state + RC[1]) 
... 
round n: state = MDS * Sbox(state + RC[n]) 
output = state

The optimized implementation instead applies the round constants after the MDS and S-box layers, and shifts them forward by one round. The first round constant is added to the input directly:

state = input + RC[0] 
round 0: state = MDS * Sbox(state) + RC[1] 
round 1: state = MDS * Sbox(state) + RC[2] 
... 
round n-1: state = MDS * Sbox(state) + RC[n] 
round n  : state = MDS * Sbox(state) output = state 

This structure mainly simplifies the polynomial identities enforced in circuit constraints. Note that this optimization is applied in the partial rounds.

Round Skipping: Batched Partial Rounds

In the standard Poseidon construction, partial rounds apply the S-box to only a single element in the state (typically the first or last). In the optimized implementation, multiple partial rounds are combined into a single constraint using a skipping mechanism.

Configuration

Two versions are supported:

pub(crate) const NB_SKIPS_CIRCUIT: usize = 5;  // For circuit
pub(crate) const NB_SKIPS_CPU: usize = 2;      // For native (CPU)

The constant NB_SKIPS_CIRCUIT controls how many partial rounds are batched together in the circuit, reducing the number of constraints per round. Note that in the paper, it mentions that 2 is the ideal number for CPU, but for constraints there are different trade-offs between different options

Round Representation

The round evaluation process is abstracted through the precomputed structure:

pub struct PreComputedRoundCircuit<F: PoseidonField> {
    pub(crate) round_constants: RoundContantsCircuit<F>,
    pub(crate) partial_round_id: RoundId<F>,
}

These store optimized constants and polynomial identities used in the round-skipping gates. A helper function evaluates these batched rounds:

fn eval<const NB_SKIPS: usize>(
    &self,
    round_constants: &[F],  // Length WIDTH + NB_SKIPS
    instances: &mut [F],     // Current state
) -> [F; NB_SKIPS]           // Intermediate results

This design enables compact representation of multiple partial rounds as a single linear combination.

Circuit Gates

The optimized Poseidon circuit defines two custom gates. Before explaining those gates let’s focus on the linear_layer function.

The linear_layer Function

The linear_layer function implements the core operation of the linear mixing step in the Poseidon permutation:

fn linear_layer<F: PoseidonField>(
    inputs: &[Expression<F>],        // Length WIDTH
    outputs: &[Expression<F>],       // Length WIDTH
    constants: &mut [Expression<F>], // Length WIDTH
) {
    for i in 0..WIDTH {
        constants[i] = constants[i].clone() - outputs[i].clone();
        for j in 0..WIDTH {
            constants[i] =
                constants[i].clone() + Expression::Constant(F::MDS[i][j]) * inputs[j].clone();
        }
    }
}

Purpose:

  • Compute the identity of the linear layer for the Poseidon round.
  • This corresponds to MDS matrix multiplication plus addition of round constants.

Inputs:

  • inputs: Current state before MDS mixing.
  • outputs: Expected state after S-box and MDS mixing.
  • constants: Mutable slice initialized with round constants.

Operation:

For each output position i, the function:

  1. Subtracts the expected output from the corresponding constant:
    constants[i]=constants[i]−outputs[i]

  2. Adds the result of the MDS linear transformation applied to inputs:
    constants[i]+=∑j=0WIDTH−1MDS[i][j]×inputs[j]

Thus, after running linear_layer, the constraints are ready to be enforced as polynomial equalities:

constants[i]=0for each i∈[0,WIDTH−1]


Implementation Notes:

  • The function mutates constants to avoid allocating new vectors.
  • It expects constants to be pre-populated with round constants.

The linear_layer is used in both full and partial round gates to validate the correctness of the MDS mixing and round constants application.

1. Full Round Gate

This gate enforces the usual round behavior where all elements are processed through the S-box and MDS matrix.

meta.create_gate("full_round_gate", |meta| {
    let mut inputs = ...;
    let outputs = ...;
    let mut identities = ...;

    for x in inputs.iter_mut() {
        *x = x.clone().square().square() * x.clone(); // x^5
    }

    linear_layer(&inputs, &outputs, &mut identities);
});

Below is the visual representation of this gate.

Row q_full_round state[0] state[1] state[2] rc[0] rc[1] rc[2]
i 1 s0 s1 s2 c0 c1 c2
i+1 - s0’ s1’ s2’ - - -

Each output (j in 0..2) is constrained as:

sj′=MDS[j][0]*(s05)+MDS[j][1]*(s15)+MDS[j][2]*(s25)+cj


What it enforces:

  • S-box (x5) applied to all elements.
  • Linear layer with MDS matrix multiplication.
  • Correct application of round constants.
  • Result matches the next state row.

2. Partial Round Gate (with Skipping)

This gate combines 1 + NB_SKIPS_CIRCUIT partial rounds into a single constraint:

meta.create_gate("partial_round_gate", |meta| {
    let inputs = ...;
    let outputs = ...;
    let round_constants = ...;

    let ids = ids.to_expression(&inputs);

    let output_lin_ids = (0..WIDTH - 1).map(|i| round_constants[i] - outputs[i] + ids[i]);

    let input_pow_ids = (WIDTH - 1..WIDTH + NB_SKIPS_CIRCUIT - 1)
        .map(|i| round_constants[i] - inputs[i + 1] + ids[i]);

    let output_pow_id =
        round_constants[WIDTH + NB_SKIPS_CIRCUIT - 1] - outputs[WIDTH - 1] + ids[WIDTH + NB_SKIPS_CIRCUIT - 1];
});

This results in three categories of constraints:

  • Linear output constraints for state elements that do not pass through the S-box.
  • Intermediate S-box constraints for skipped rounds.
  • Final S-box constraint for the last state element after all skipped rounds.

Below is the visual representation of the constraints

Row q_partial_round state[0] state[1] state[2] aux[0..4] rc[0..7]
i 1 s0 s1 s2 a0 a1 a2 a3 a4 c0 c1 c2 c3 c4 c5 c6 c7
i+1 - s0’ s1’ s2’ - -

Constraints:

  1. Linear Output (state[0], state[1]):
s0′=linear_combination(...)+c0


s1′=linear_combination(...)+c1
  1. S-box Intermediate Values (aux[0..4]):
ak=(ak)5+c2+k
  1. Final Output (state[2]):
s2′=(a4)5+c7


Only state[2] passes through the S-box in each of the skipped rounds. The auxiliary columns store intermediate S-box values, and all constants are precomputed to match the expected outputs.

Sgn0

The sgn0 function extracts the least significant bit of a field element: by which is meant the least significant bit of the canonical representation of the field element; the unique integer e∈ℕ<q st. e≡xmodq. The implementation is as follows and we explain its correctness below:

fn sgn0(
    &self,
    layouter: &mut impl Layouter<F>,
    x: &AssignedNative<F>,
) -> Result<AssignedBit<F>, Error> {
    // We decompose x as msb || mid_limb || lsb.

    let n = F::NUM_BITS as u64;
    let msb_factor = big_to_fe::<F>(BigUint::one() << (n - 1));

    let x_val = x.value().copied().map(fe_to_big);
    let lsb_val = x_val.clone().map(|x| Bit(x.bit(0)));
    let msb_val = x_val.clone().map(|x| Bit(x.bit(n - 1)));
    let mid_val = x_val.map(|x| {
        let mut x = x;
        x.set_bit(n - 1, false);
        big_to_fe::<F>(x >> 1)
    });

    let x_lsb: AssignedBit<F> = self.assign(layouter, lsb_val)?;
    let x_msb: AssignedBit<F> = self.assign(layouter, msb_val)?;
    let x_mid = self.core_decomposition_chip.assign_less_than_pow2(
        layouter,
        mid_val,
        n as usize - 2,
    )?;

    let must_be_x = self.linear_combination(
        layouter,
        &[
            (F::ONE, x_lsb.clone().into()),
            (F::from(2), x_mid.clone()),
            (msb_factor, x_msb.clone().into()),
        ],
        F::ZERO,
    )?;

    self.assert_equal(layouter, x, &must_be_x)?;

    // At this point we have that (x_msb, x_mid, x_lsb) is a valid split of x.
    // We want to make sure it is canonical before we can output x_lsb.
    // Let (1, p_mid, 1) be the representation of the native modulus.
    // For canonicity, we will check that one of the following is true:
    //   (1) x_msb = 1      =>  x_mid <= p_mid
    //   (2) x_mid = p_mid  =>  x_lsb = 0
    let p = modulus::<F>();
    debug_assert!(p.bit(0));
    debug_assert!(p.bit(n - 1));
    let p_mid: BigUint = (p - fe_to_big(msb_factor)) >> 1;

    // Check condition (1)
    let zero = self.assign_fixed(layouter, F::ZERO)?;
    let aux = self.select(layouter, &x_msb, &x_mid, &zero)?;
    self.assert_lower_than_fixed(layouter, &aux, &(p_mid.clone() + BigUint::one()))?;

    // Check condition (2)
    let same_mid = self.is_equal_to_fixed(layouter, &x_mid, big_to_fe(p_mid))?;
    let same_mid_and_lsb_set = self.and(layouter, &[same_mid, x_lsb.clone()])?;
    self.assert_equal_to_fixed(layouter, &same_mid_and_lsb_set, Bit(false))?;

    Ok(x_lsb)
}

First the value x∈𝔽q is decomposed into (range checked) parts:

  • xlsb∈{0,1}
  • xmid∈[0,…,2n−1)
  • xmsb∈{0,1}

Such that:

  • x=xmsb·2n−1+xmid·2+xlsb

Since this representation is not unique, i.e. q<2n−1, we need to ensure that the decomposition is the canonical one: corresponding to the integer e∈ℕ<q described at the beginning of this section. As indicated by the comment in the code, this is done by enforcing the following two conditions:

  • xmsb=1 ⟹ xmid≤pmid
  • xmid=pmid ⟹ xlsb=0

The first condition covers most of the non-canonical decompositions, namely any non-canonical decomposition must have xmsb=1, matching the most significant bit of q, which then implies that for the representative to be larger than q, the xmid| xlsb must be greater than pmid|‖1. The one condition not handled by the first requirement is exactly when xmid=pmid and xlsb=1. Corresponding to a decomposition of 0∈𝔽q as q∈ℤ. The last condition serves to account for this case, to enforce it is rewritten by replacing the implication with a disjunction and a negation, then applying DeMorgan’s law:

xmid=pmid ⟹ xlsb=0

Rewrite the implication as a disjunction:

xmid≠pmid∨xlsb=1

And by De Morgan’s law:

¬(xmid=pmid∧xlsb=1)

BigUInt

In addition to the foreign field emulation, it also has the biguint module to emulate arbitrary large integers. It is a useful module for the use cases like RSA verification.

Similarly, it has a normalization process to correct the overflowed limbs. The process is relatively simpler than the one for foreign field emulation.

Limb Representation

The module biguint can represent an arbitrary large integer represented by decomposed limbs. The default bound for each limb is 0≤xi<B, where B has 96 bits.

A biguint can be decomposed and encoded into a circuit using the assign_bounded function, which takes the nb_bits to decide how many limbs to create and the bounds for each limb.

X=∑i=0n−1xi·Bi

The bounds for the limbs are the same as B, except for the most significant limb.

xi={0≤xi<B,0≤i<n−10≤xi<Bmsl,i=n−1

Code for the limb bounds:

let mut limb_size_bounds = vec![LOG2_BASE; nb_limbs - 1];
limb_size_bounds.push((nb_bits - 1).rem(LOG2_BASE) + 1); // msl bound

Code for range check the limbs:

let limbs = value
    .map(|x| big_to_limbs(nb_limbs as u32, &(BigUint::one() << LOG2_BASE), &x))
    .transpose_vec(nb_limbs)
    .into_iter()
    .zip(limb_size_bounds.iter())
    .map(|(limb_value, size_bound)| {
        self.native_gadget.assign_lower_than_fixed(
            layouter,
            limb_value.map(big_to_fe::<F>),
            &(BigUint::one() << *size_bound),
        )
    })
    .collect::<Result<Vec<_>, Error>>()?;

During operations, such as Addition or Multiplication, the value can overflow its limb’s bound. In biguint module, there is also a normalization process to correct these limb overflows. This procedure relies on the bound tracker at the compile time to determine whether or not to normalize a biguint.

Bound Adjustment

The basic operations Addition, Subtraction and Multiplication also calculate the limb bounds for the resulted biguint.

Addition and Subtraction Operations

In addition operation, when two limbs sum up, the resulted value can overflow the upper bound of them.

zi=xi+yi

So it needs to add one bit to the upper bound in order to represent the carry bit during normalization.

bzi=1+max(bxi, byi)

Where b represents the bound for the limbs.

limbs.push(self.native_gadget.add(layouter, &x.limbs[i], &y.limbs[i])?);
limb_size_bounds.push(bound_of_addition(
    x.limb_size_bounds[i],
    y.limb_size_bounds[i],
));

On the other hand, the subtraction simply picks the bounds of the left hand side, as it assumes the LHS is always greater than or equal to the RHS.

bzi=bxi
let res = self.assign_bounded(layouter, res_value, x.nb_bits())?;
let z = self.add(layouter, &res, y)?; // (x - y) + y
self.assert_equal(layouter, x, &z)?;
Ok(res)

Multiplication Operation

Supposed there are two biguint, X and Y, their multiplication can be represented as Z.

X=∑i=0n−1xi·Bi,Y=∑j=0m−1yj·Bj X·Y=∑i=0n−1∑j=0m−1xi·yj·Bi+j Z=X·Y=∑k=0n+m−2zk·Bk

Let Zk represents the resulted limbs:

zk=∑k=i+jxi·yj

As the bound for the zk is bzk=bxi+byj, and there are accumulations to zk, thus the bound should be accumulated as:

bzk=1+max(zk, bxi+byi)
for i in 0..x.limbs.len() {
    for j in 0..y.limbs.len() {
        let p = native_gadget.mul(layouter, &x.limbs[i], &y.limbs[j], None)?;
        let p_bound = x.limb_size_bounds[i] + y.limb_size_bounds[j];
        limbs[i + j] = native_gadget.add(layouter, &limbs[i + j], &p)?;
        limb_size_bounds[i + j] = bound_of_addition(limb_size_bounds[i + j], p_bound);
    }
}

Normalization Process

Similar to the normalization in the foreign field module, it also need to run normalization procedure to convert the limbs with overflow values to fall within the expected bound.

The limb overflow can happen during the basic operations addition and multiplication. These operations also update each bound of the limbs in order to keep track of whether it needs to be normalized and how to normalize.

Before starting the normalization procedure, the normalize() check if the limb bounds representing a biguint is within the default bound, 96 bits.

pub fn is_normalized(&self) -> bool {
    self.limb_size_bounds
        .iter()
        .all(|bound| *bound <= LOG2_BASE)
}

Note that this seems not strictly aligning with the actual bound of the most significant limb. Since the operations, involving updating the bounds, will update bounds for all the limbs. Thus, a single update to the limb bounds can lead the biguint to be hint by the normalization process eventually. So far, it is fine this is_normalized check doesn’t strictly align with the actual bound of the most significant limb.

First, it calculates the number of limbs based on the number of bits, which are derived from the max limb bounds.

let nb_limbs_output = x.nb_bits().div_ceil(LOG2_BASE) as usize;

Second, it goes through each potential overflow limb to calculate the payload of the limb. Note that the carry0 is 0. payloadi=xi+carryi

let payload = native_gadget.add(layouter, &carry, &x.limbs[i])?;
let payload_bound = bound_of_addition(carry_size_bound, x.limb_size_bounds[i]);

Third, it normalizes the limb xi:

xi′=payloadimodB

Before proceeding to the next limb, it calculates the carryi+1.

carryi+1=⌊payloadiB⌋
let (q, limb) = self.div_rem_native_by_base(layouter, &payload, payload_bound)?;
// Prepare the carry and its bound for the next iteration.
carry_size_bound = max(payload_bound, LOG2_BASE) - LOG2_BASE;
carry = q;

The div_rem_native_by_base function is used to calculate the quotient q and the remainder limb of the division by the base B.

payloadi=q·B+limbnormalized

It relies on the payload_bound to range check the q, the carryi+1. After all limbs are processed, it ensures the final carry is 0.

Plonk API Utils

The Plonk API Utils (src/utils/plonk_api.rs) provide a set of utilities and helper functions for set up, proving, and verifying PLONK circuits using Midnight’s Halo2 fork.

The main functionalities include:

  • Proving and Verification Utilities: The plonk_api! macro generates structs that expose standardized methods for setting up verifying keys (setup_vk), proving keys (setup_pk), creating proofs (prove), and verifying proofs (verify). These abstractions can be instantiated for several curves.

  • Verification Key Management: The check_vk function ensures that verification keys for circuits remain consistent across code changes. It compares the computed VK hash against stored values, enforcing versioning policies:

    • If a VK is missing, a minor version bump is required.
    • If a VK differs, a breaking change must be recorded.
  • SRS Handling for Filecoin’s Trusted Setup: The filecoin_srs function loads a Structured Reference String (SRS) compatible with BLS12-381 Filecoin trusted setup. If necessary, it can download and parse raw SRS data from specified sources and downsize the parameters to the required circuit size.