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.
The following files/directories were the scope of the audit:
This section provides an overview of the Midnight library and circuits that were within the scope of this audit.
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
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>;
}
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())
}
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.
The ParserGadget provides core functionality for fetching sequences of native elements and byte arrays. It offers two main functions:
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>>
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.
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.
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.
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.
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.
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
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.
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:
-
Subtracts the expected output from the corresponding constant:
-
Adds the result of the MDS linear transformation applied to inputs:
Thus, after running linear_layer, the constraints are ready to be enforced as polynomial equalities:
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:
What it enforces:
- S-box () applied to all elements.
- Linear layer with MDS matrix multiplication.
- Correct application of round constants.
- Result matches the next state row.
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:
- Linear Output (state[0], state[1]):
- S-box Intermediate Values (aux[0..4]):
- Final Output (state[2]):
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.
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 st. .
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 is decomposed into (range checked) parts:
Such that:
Since this representation is not unique, i.e. , we need to ensure that the decomposition is the canonical one: corresponding to the integer described at the beginning of this section.
As indicated by the comment in the code, this is done by enforcing the following two conditions:
The first condition covers most of the non-canonical decompositions,
namely any non-canonical decomposition must have , matching the most significant bit of ,
which then implies that for the representative to be larger than , the must be greater than .
The one condition not handled by the first requirement is exactly when and .
Corresponding to a decomposition of as .
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:
Rewrite the implication as a disjunction:
And by De Morgan’s law:
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.
The module biguint can represent an arbitrary large integer represented by decomposed limbs. The default bound for each limb is , where 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.
The bounds for the limbs are the same as , except for the most significant limb.
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.
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.
So it needs to add one bit to the upper bound in order to represent the carry bit during normalization.
Where 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.
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, and , their multiplication can be represented as .
Let represents the resulted limbs:
As the bound for the is , and there are accumulations to , thus the bound should be accumulated as:
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);
}
}
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 is 0.
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 :
Before proceeding to the next limb, it calculates the .
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 .
It relies on the payload_bound to range check the q, the . After all limbs are processed, it ensures the final is 0.
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.
Below are listed the findings found during the engagement. High severity findings can be seen as
so-called
"priority 0" issues that need fixing (potentially urgently). Medium severity findings are most often
serious
findings that have less impact (or are harder to exploit) than high-severity findings. Low severity
findings
are most often exploitable in contrived scenarios, if at all, but still warrant reflection. Findings
marked
as informational are general comments that did not fit any of the other criteria.