noir-bigint
is a library that implements big-integers in Noir as a utility to develop other cryptographic primitives and tools for the Noir ecosystem. In this document, you will find all the documentation related with the implementation of the mentioned library from both theoretical and practical perspective.
The goal is to implement a library that evaluates operations modulo an integer
Once the representation is defined, we want to expose an API that allows developers to write modular arithmetic zero-knowledge proofs using this big-integer modulus representation. Concretely, given
The implementation and this documentation follow the ideas presented in the blog post "Big integer multiplication in Noir over arbitrary moduli" by Zachary James Williamson. We encourage readers to read the blog post to familiarize themselves with the initial ideas. However, much of the design considered in this implementation and documentation has changed significantly compared to the ideas presented in the blog post.
In the context of ZK-proofs, choosing
In the rest of the document and the implementation, we will consider
The building block to construct a big-integer library is the BigNum struct:
struct BigNum<let N: u64, Params> {
limbs: [Field; N]
}
This struct represents a big-integer with Field
element whose value is in the range Field
element typically can store more than Field
to use in case of an overflow while operating Field
element depends on the backend used to run the proof. For the rest of the discussion, we will assume that the number of bits stored in a Field
element is 254, the bit length given by the curve in the default backend. Considering this representation, if a number is represented using the vector
The API is designed around this struct so the addition and multiplication are defined as methods implemented for this struct.
During the library's implementation, we require some utilities that allow us to implement the arithmetic operations in an idiomatic way. Here, we describe those utilities and their usage inside the main arithmetic algorithms.
The struct ArrayX
is an implementation of an array whose length is the product of a known multiplier SizeMultiplier
and ArrayX
where SizeMultiplier
is two. This implementation is a workaround to have arrays whose length is
The struct for ArrayX
is presented next:
struct ArrayX<T, let N: u64, let SizeMultiplier: u8> {
segments: [[T; N]; SizeMultiplier]
}
Notice that ArrayX
is represented in a matrix fashion. However, it is important to remember that this can be considered an array of length SizeMultiplier * N
.
This array implementation contains some methods of interest:
- The array implements the functions
set()
andget()
to modify and retrieve a certain index from the array. The index of the array that is being queried needs to be between 0 andSizeMultiplier * N - 1
. - The implementation of the array also has some arithmetic operations like
add_assign()
,sub_assign()
, andmul_assign()
that allow to do the arithmetic operations in place.
One of the main uses of ArrayX
is to transform a number from a 120-bit radix representation to a 60-bit radix representation. When we transform over a number with SizeMultiplier
equal to two.
The U60Repr
struct represents a big integer as an array of 60-bit limbs. The main use of this struct is to convert the big integers in the 120-bit limbs into this representation to perform additions and subtraction in a lower number of bits to avoid overflow. This overflow is avoided because the sum of two 60-bit limbs is, at most, 61 bits and can be stored using the u64
type.
The U60Repr
struct is defined as an ArrayX of u64 elements:
struct U60Repr<let N: u32, let NumSegments: u32> {
limbs: ArrayX<u64, N, NumSegments>
}
There are some methods of interest in this representation:
U60Repr
implements the traitsstd::ops::Add
andstd::ops::Sub
which allow to the programmer to use the operators+
and-
between twoU60Repr
. Those traits are implemented using the schoolbook addition and subtraction in which the operation is performed limb by limb, starting from the least significant limb and storing the carry value for the next limb operation. We suggest reading "The Art of Computer Programming Volume 2" by Donal Knuth in section 4.3 for more information about how the schoolbook addition and subtraction works.U60Repr
implements the traitstd::convert::From<[Field; N]>
, transforming a big-integer number with a 120-bit radix representation into a 60-bit limb representation.U60Repr
implements the traitstd::convert::Into<[Field; N]>
to convert from 60-bit to 120-bit radix representation.
Using the above methods, the strategy consists of taking big integers in the 120-bit representation and converting them into a 60-bit representation. Then, we perform the additions or subtractions in this reduced representation, and we transform the resulting big integer back to the 120-bit representation to continue with the rest of the circuit. It is important to consider that most of the methods of U60Repr
are executed using unconstrained functions to avoid the overhead that comes from using u64
data types and comparing them.
First, let us remember the goal at hand. In the context of ZK-proofs, a prover wants to prove that they know
One of the most important parts of the algorithm is constraining to the condition
Here
-
$N_P$ is the number of products that will be computed, -
$N_L$ is the number of products in the left hand side, -
$N_R$ is the number of products in the right hand side, -
$N_A$ is the number of linear terms in the expression, -
$L$ is a colection of terms in the left hand side, -
$R$ is a colection of terms in the right hand side, and -
$A$ is the colection of linear terms.
First, the function computes the quotient t0
and t1
respectively. The same thing holds for the linear terms in t4
. In the construction of t0
, t1
, and t4
, we subtract the negative terms, but then we add BigNum
After evaluating t1
, t2
and t4
, we compute ArrayX
to store the limbs. This is because the product of t1
and t2
will give us
After computing the whole expression, we need to apply the borrow to obtain the limbs in 120 bits. Here, we define the borrowed value to
In the last step, we must check that the condition
In the computation of the quotient and the borrow flags, we will compute the quotient
First, the function computes the negative and positive terms in the whole expression in two data structures. The data structure for the negative numbers will store the corresponding sum of all the negative numbers with a positive sign. Notice that we are multiplying numbers of
After obtaining the terms in the final result, we normalize each limb to have 120 bits. Then, we convert them into an array of a 60-bit limb. Remember that we obtained ArrayX<N, 4>
(an array of
In the realm of 60-bit limbs, we perform the subtraction, convert it again into 120-bit limbs, and normalize them. In this way, we have computed the entire quadratic expression in a normalized format.
Once we have the final expression, we compute
Finally, we compute the borrow flags. For an expression of
- The positive accumulator of the limb is added with the value represented in the most significant bits located from bit 121 onwards. Notice that the 120 lowest significant bits of the limb
$i - 1$ are the actual value in the 120-bit limb representation. - We compute a boolean to check if there is an overflow. The boolean for the limb
$i$ is computed by checking if$\text{PositiveAccumulator}[i] < \text{NegativeAccumulator}[i] + BF[i-1] \cdot 2^{126}$ , where$BF$ is the vector of borrow flags. The value of$2^{126}$ appears here because$2^{126} = 2^{246} - 2^{120}$ is the value that is subtracted from the current limb as borrow to the previous limb in position$i-1$ . Remember that if the previous limb in position$i-1$ underflows ($BF[i-1]$ ), then it gets added$2^{246}$ which translates in a subtraction of$2^{246-120}$ from the limb$i$ . Once the boolean is computed, it is stored in$BF[i]$ . - We compute the value represented by the bits from position 121 onwards for the current limb
$i$ as follows:- Subtract the negative accumulator from the positive accumulator
- If the subtraction in the current limb underflows, we need to add
$2^{246}$ as borrow from the next limb$i + 1$ - We need to subtract
$2^{126} = 2^{246} - 2^{120}$ in case that the previous limb$i-1$ required a borrow from the current limb$i$ . - We chop the 120 least significant bits by multiplying the current value by
$2^{-120}$ . We proceed with the previous steps until we reach the last limb, and then we return the quotient$q$ and the vector of borrow flags$BF$ .
Once we have a mechanism to constrain arbitrary quadratic expressions, we can do the addition as an unconstrained operation and then constrain it with evaluate_quadratic_expression()
.
To compute the arithmetic operation BigNum
elements using the unconstrained functoin __addmod()
. This method converts the 120-bit limb representation to 60-bit limb representation, adds the two vectors and reduces the value modulo
Once we have the addition, we need to constrain the result. This can be done using the evaluate_quadratic_expression()
constraining to the expression
To do the multiplication, we first multiply two BigNum
instances using an unconstrained function, and then we constrain the multiplication using the constrained function evaluate_quadratic_expression()
.
For the unconstrained multiplication function, we have two different flavors for multiplying two
We next describe both approaches.
In the schoolbook approach, to multiply two result[i + j] += a[i] * b[j]
. where
Notice that the schoolbook multiplication has a
Then, the result will be the limbs
It is important to mention that we can do some levels of the recursion using the Karatsuba approach and finish the recursion using schoolbook multiplication. However, we need to perform a performance evaluation to determine whether it is appropriate to stop the Karatsuba recursion and finish it using schoolbooks. The evaluation is left as future work.
Due to Noir's limitations, the Karatsuba method can only be implemented for a fixed and predefined number of limbs. Therefore, we have implemented Karatsuba multiplication for the following values of