The Fast Reed-Solomon Interactive Oracle Proofs of Proximity (FRI) is a cryptographic protocol that allows a prover to prove to a verifier (in an interactive, or non-interactive fashion) that a hash-based commitment (e.g. a Merkle tree of evaluations) of a vector of values represent the evaluations of a polynomial of some known degree. (That is, the vector committed is not just a bunch of uncorrelated values.) The algorithm is often referred to as a "low degree" test, as the degree of the underlying polynomial is expected to be much lower than the degree of the field the polynomial is defined over. Furthermore, the algorithm can also be used to prove the evaluation of a committed polynomial, an application that is often called FRI-PCS. We discuss both algorithms in this document, as well as how to batch multiple instances of the two algorithms.
For more information about the original construction, see Fast Reed-Solomon Interactive Oracle Proofs of Proximity. This document is about the specific instantiation of FRI and FRI-PCS as used by the StarkNet protocol.
draft
We briefly give an overview of the FRI protocol, before specifying how it is used in the StarkNet protocol.
FRI is a protocol that works by successively reducing the degree of a polynomial, and where the last reduction is a constant polynomial of degree . Typically the protocol obtains the best runtime complexity when each reduction can halve the degree of its input polynomial. For this reason, FRI is typically described and instantiated on a polynomial of degree a power of .
If the reductions are "correct", and it takes reductions to produce a constant polynomial in the "last layer", then it is a proof that the original polynomial at "layer 0" was of degree at most .
In order to ensure that the reductions are correct, two mechanisms are used:
To illustrate how FRI works, one can use sagemath with the following setup:
# We use the starknet field (https://docs.starknet.io/architecture-and-concepts/cryptography/p-value/)
starknet_prime = 2^251 + 17*2^192 + 1
starknet_field = GF(starknet_prime)
polynomial_ring.<x> = PolynomialRing(starknet_field)
# find generator of the main group
gen = starknet_field.multiplicative_generator()
assert gen == 3
assert starknet_field(gen)^(starknet_prime-1) == 1 # 3^(order-1) = 1
# lagrange theorem gives us the orders of all the multiplicative subgroups
# which are the divisors of the main multiplicative group order (which, remember, is p - 1 as 0 is not part of it)
# p - 1 = 2^192 * 5 * 7 * 98714381 * 166848103
multiplicative_subgroup_order = starknet_field.order() - 1
assert list(factor(multiplicative_subgroup_order)) == [(2, 192), (5, 1), (7, 1), (98714381, 1), (166848103, 1)]
# find generator of subgroup of order 2^192
# the starknet field has high 2-adicity, which is useful for FRI and FFTs
# (https://www.cryptologie.net/article/559/whats-two-adicity)
gen2 = gen^( (starknet_prime-1) / (2^192) )
assert gen2^(2^192) == 1
# find generator of a subgroup of order 2^i for i <= 192
def find_gen2(i):
assert i >= 0
assert i <= 192
return gen2^( 2^(192-i) )
assert find_gen2(0)^1 == 1
assert find_gen2(1)^2 == 1
assert find_gen2(2)^4 == 1
assert find_gen2(3)^8 == 1
A reduction in the FRI protocol is obtained by interpreting an input polynomial as a polynomial of degree and splitting it into two polynomials and of degree such that .
Then, with the help of a verifier's random challenge , we can produce a random linear combination of these polynomials to obtain a new polynomial of degree :
def split_poly(p, remove_square=True):
assert (p.degree()+1) % 2 == 0
g = (p + p(-x))/2 # <---------- nice trick!
h = (p - p(-x))//(2 * x) # <--- nice trick!
# at this point g and h are still around the same degree of p
# we need to replace x^2 by x for FRI to continue (as we want to halve the degrees!)
if remove_square:
g = g.parent(g.list()[::2]) # <-- (using python's <!--CODE_BLOCK_33--> syntax)
h = h.parent(h.list()[::2])
assert g.degree() == h.degree() == p.degree() // 2
assert p(7) == g(7^2) + 7 * h(7^2)
else:
assert g.degree() == h.degree() == p.degree() - 1
assert p(7) == g(7) + 7 * h(7)
return g, h
We can look at the following example to see how a polynomial of degree is reduced to a polynomial of degree in 3 rounds:
# p0(x) = 1 + 2x + 3x^2 + 4x^3 + 5x^4 + 6x^5 + 7x^6 + 8x^7
# degree 7 means that we'll get ceil(log2(7)) = 3 rounds (and 4 layers)
p0 = polynomial_ring([1, 2, 3, 4, 5, 6, 7, 8])
# round 1: moves from degree 7 to degree 3
h0, g0 = split_poly(p0)
assert h0.degree() == g0.degree() == 3
zeta0 = 3 # <-------------------- the verifier would pick a random zeta
p1 = h0 + zeta0 * g0 # <--------- the prover would send a commitment of p1
assert p0(zeta0) == p1(zeta0^2) # <- sanity check
# round 2: reduces degree 3 to degree 1
h1, g1 = split_poly(p1)
assert g1.degree() == h1.degree() == 1
zeta1 = 12 # <------------ the verifier would pick a random zeta
p2 = h1 + zeta1 * g1 # <-- the prover would send a commitment of p2
assert p1(zeta1) == p2(zeta1^2)
h2, g2 = split_poly(p2)
assert h2.degree() == g2.degree() == 0
# round 3: reduces degree 1 to degree 0
zeta2 = 3920 # <---------- the verifier would pick a random zeta
p3 = h2 + zeta2 * g2 # <-- the prover could send p3 in the clear
assert p2(zeta2) == p3
assert p3.degree() == 0
In the real FRI protocol, each layer's polynomial would be sent using a hash-based commitment (e.g. a Merkle tree of its evaluations over a large domain). As such, the verifier must ensure that each commitment consistently represents the proper reduction of the previous layer's polynomial. To do that, they "query" commitments of the different polynomials of the different layers at points/evaluations. Let's see how this works.
Given a polynomial and two of its evaluations at some points and , we can see that the verifier can recover the two halves by computing:
Then, the verifier can compute the next layer's evaluation at as:
We can see this in our previous example:
# first round/reduction
v = 392 # <-------------------------------------- fake sample a point
p0_v, p0_v_neg = p0(v), p0(-v) # <--------------- the 2 queries we need
g0_square = (p0_v + p0_v_neg)/2
h0_square = (p0_v - p0_v_neg)/(2 * v)
assert p0_v == g0_square + v * h0_square # <------ sanity check
In practice, to check that the evaluation on the next layer's polynomial is correct, the verifier would "query" the prover's commitment to the polynomial. These queries are different from the FRI queries (which enforce consistency between layers of reductions), they are evaluation queries or commitment queries and result in practice in the prover providing a Merkle membership proof (also called decommitment in this specification) to the committed polynomial.
As we already have an evaluation of of the next layer's polynomial , we can simply query the evaluation of to continue the FRI query process on the next layer, and so on:
p1_v = p1(v^2) # <-------------------------------- query on the next layer
assert g0_square + zeta0 * h0_square == p1_v # <--- the correctness check
# second round/reduction
p1_v_neg = p1(-v^2) # <-- the 1 query we need
g1_square = (p1_v + p1_v_neg)/2 # g1(v^4)
h1_square = (p1_v - p1_v_neg)/(2 * v^2) # h1(v^4)
assert p1_v == g1_square + v^2 * h1_square # p1(v^2) = g1(v^4) + v^2 * h1(v^4)
p2_v = p2(v^4) # <-- query the next layer
assert p2(v^4) == g1_square + zeta1 * h1_square # p2(v^4) = g1(v^4) + zeta1 * h1(v^4)
# third round/reduction
p2_v_neg = p2(-v^4) # <-- the 1 query we need
g2_square = (p2_v + p2_v_neg)/2 # g2(v^8)
h2_square = (p2_v - p2_v_neg)/(2 * v^4) # h2(v^8)
assert p2_v == g2_square + v^4 * h2_square # p2(v^4) = g2(v^8) + v^4 * h2(v^8)
assert p3 == g2_square + zeta2 * h2_square # we already received p3 at the end of the protocol
Section 3.11.1 "Skipping FRI Layers" of the ethSTARK paper describes an optimization which skips some of the layers/rounds. The intuition is the following: if we removed the first round commitment (to the polynomial ), then the verifier would not be able to:
The first point is fine, as there's nothing to check the correctness of. To address the second point, we can use the same technique we use to compute . Remember, we needed and to compute and . But to compute and , we need the quadratic residues of , that is , such that , so that we can compute and from and .
We can easily compute them by using (tau
), the generator of the subgroup of order :
tau = find_gen2(2)
assert tau.multiplicative_order() == 4
# so now we can compute the two roots of -v^2 as
assert (tau * v)^2 == -v^2
assert (tau^3 * v)^2 == -v^2
# and when we query p2(v^4) we can verify that it is correct
# if given the evaluations of p0 at v, -v, tau*v, tau^3*v
p0_tau_v = p0(tau * v)
p0_tau3_v = p0(tau^3 * v)
p1_v_square = (p0_v + p0_v_neg)/2 + zeta0 * (p0_v - p0_v_neg)/(2 * v)
p1_neg_v_square = (p0_tau_v + p0_tau3_v)/2 + zeta0 * (p0_tau_v - p0_tau3_v)/(2 * tau * v)
assert p2(v^4) == (p1_v_square + p1_neg_v_square)/2 + zeta1 * (p1_v_square - p1_neg_v_square)/(2 * v^2)
Section 3.11.2 "FRI Last Layer" of the ethSTARK paper describes an optimization which stops at an earlier round. We show this here by removing the last round.
At the end of the second round we imagine that the verifier receives the coefficients of ( and ) directly:
p2_v = h2 + v^4 * g2 # they can then compute p2(v^4) directly
assert g1_square + zeta1 * h1_square == p2_v # and then check correctness
Given a polynomial and an evaluation point , a prover who wants to prove that can prove the related statement for some quotient polynomial of degree :
(This is because if then should be a root of and thus the polynomial can be factored in this way.)
Specifically, FRI-PCS proves that they can produce such a (commitment to a) polynomial .
To prove that two polynomials and exist and are of degree at most , a prover simply shows using FRI that a random linear combination of and exists and is of degree at most .
Note that if the FRI check might need to take into account the different degree checks that are being aggregated. For example, if the polynomial should be of degree at most but the polynomial should be of degree at most then a degree correction needs to happen. We refer to the ethSTARK paper for more details as this is out of scope for this specification. (As used in the STARK protocol targeted by this specification, it is enough to show that the polynomials are of low degree.)
Besides obvious missing implementation details from the description above, the protocol is pretty much instantiated as is, except for a few changes to the folding and querying process.
As explained above, in the "vanilla FRI" protocol the verifier gets evaluations of and and computes the next layer's evaluation at as
which is equivalent to
where
The first difference in this specification is that, assuming no skipped layers, the folded polynomial is multiplied by 2:
This means that the verifier has to modify their queries slightly by not dividing by 2:
The second difference is that while the evaluations of the first layer happen in a coset called evaluation domain, further evaluations happen in the original (blown up) trace domain (which is avoided for the first polynomial as it might lead to divisions by zero with the polynomials used in the Starknet STARK protocol). To do this, the prover defines the first reduced polynomial as:
Notice that the prover has also multiplied the right term with . This is a minor change that helps with how the verifier code is structured.
This means that the verifier computes the queries on at points on the original subgroup. So the queries of the first layer are produced using (assuming no skipped layers).
After that, everything happens as normal (except that now the prover uses the original blown-up trace domain instead of a coset to evaluate and commit to the layer polynomials).
Note that these changes can easily be generalized to work when layers are skipped.
In this section we list all the dependencies and interfaces this standard relies on.
We rely on two type of hash functions:
See the Channel specification for details.
As part of the protocol, the prover must provide a number of evaluations of the first layer polynomial (based on the FRI queries that the verifier generates in the query phase of the protocol).
We abstract this here as an oracle that magically provides evaluations. It is the responsibility of the user of this protocol to ensure that the evaluations are correct (which most likely include verifying a number of decommitments). See the Starknet STARK verifier specification for a concrete usage example.
We use the following constants throughout the protocol.
STARKNET_PRIME = 3618502788666131213697322783095070105623107215331596699973092056135872020481
. The Starknet prime ().
FIELD_GENERATOR = 3
. The generator for the main multiplicative subgroup of the Starknet field. This is also used as the coset factor to produce the coset used in the first layer's evaluation.
MAX_LAST_LAYER_LOG_DEGREE_BOUND = 15
. The maximum degree of the last layer polynomial (in log2).
MAX_FRI_LAYERS = 15
. The maximum number of layers in the FRI protocol.
MAX_FRI_STEP = 4
. The maximum number of layers that can be involved in a reduction in FRI (see the overview for more details). This essentially means that each reduction (except for the first as we specify later) can skip 0 to 3 layers.
This means that the standard can be implemented to test that committed polynomials exist and are of degree at most .
As explained in the overview, skipped layers must involve the use of elements of the subgroups of order for the number of layers included in a step (from 1 to 4 as specified previously).
As different generators can generate the same subgroups, we have to define the generators that are expected. Instead, we define the inverse of the generators of groups of different orders (as it more closely matches the code):
const OMEGA_16: felt252 = 0x5c3ed0c6f6ac6dd647c9ba3e4721c1eb14011ea3d174c52d7981c5b8145aa75;
const OMEGA_8: felt252 = 0x446ed3ce295dda2b5ea677394813e6eab8bfbc55397aacac8e6df6f4bc9ca34;
const OMEGA_4: felt252 = 0x1dafdc6d65d66b5accedf99bcd607383ad971a9537cdf25d59e99d90becc81e;
const OMEGA_2: felt252 = -1
So here, for example, OMEGA_8
is where is the generator of the subgroup of order that we later use in the Verify A Layer's Query section.
The FRI protocol is globally parameterized according to the following variables. For a real-world example, check the Starknet STARK verifier specification.
n_verifier_friendly_commitment_layers
. The number of layers (starting from the bottom) that make use of the circuit-friendly hash.
proof_of_work_bits
. The number of bits required for the proof of work. This value should be between 20 and 50.
A FRI configuration contains the following fields:
log_input_size
. The size of the input layer to FRI, specifically the log number of evaluations committed (this should match the log of the evaluation domain size).
n_layers
. The number of layers or folding that will occur as part of the FRI proof. This value must be within the range [2, MAX_FRI_LAYERS]
(see constants).
inner_layers
. An array of TableCommitmentConfig
where each configuration represents what is expected of each commitment sent as part of the FRI proof. Refer to the Table Commitments section of the Starknet Merkle Tree Polynomial Commitments specification.
fri_step_sizes
. The number of layers to skip for each folding/reduction of the protocol. The first step must always be zero, as no layer is skipped during the first reduction. Each step should be within the range [1, MAX_FRI_STEP]
. For each step, the corresponding layer inner_layers[i-1]
should have enough columns to support the reduction: n_columns = 2^fri_step
.
log_last_layer_degree_bound
. The degree of the last layer's polynomial. As it is sent in clear as part of the FRI protocol, this value represents the (log) number of coefficients (minus 1) that the proof will contain. It must be less or equal to MAX_LAST_LAYER_LOG_DEGREE_BOUND
(see constants).
In addition, the following validations should be performed on passed configurations:
fri_step_sizes[i]
check:inner_Layers[i-1]
hasn_verifier_friendly_commitment_layers
log_expected_input_degree + log_n_cosets == log_input_size
log_expected_input_degree = sum_of_step_sizes + log_last_layer_degree_bound
TODO: move these validation steps in the description of the fields above
There are three types of domains:
The trace domain, this is the domain chosen to evaluate the execution trace polynomials. It is typically the smallest subgroup of order for some , such that it can include all the constraints of an AIR constraint system. A generator for the trace domain can be found as (since )
The blown-up trace domain, which is chosen as a subgroup of a power of two that encompasses the trace domain (i.e. ). The "blown up factor" typically dictates how much larger the evaluation domain is as a multiple. A generator for the blown-up trace domain can be found as .
The evaluation domain, This is a coset of the blown-up domain, computed using the generator of the main group: .
Commitments are created using table commitments as described in the Table Commitments section of the Merkle Tree Polynomial Commitments specification.
For the first layer polynomial, the evaluations being committed are in a coset called the evaluation domain.
For all other polynomials, commitments are made up of evaluations in the blown-up trace domain (following the correction outlined in the Notable Differences With Vanilla FRI section).
A FRI proof looks like the following:
struct FriUnsentCommitment {
// Array of size n_layers - 1 containing unsent table commitments for each inner layer.
inner_layers: Span<felt252>,
// Array of size 2**log_last_layer_degree_bound containing coefficients for the last layer
// polynomial.
last_layer_coefficients: Span<felt252>,
}
The FRI protocol is split into two phases:
We go through each of the phases in the next two subsections.
The commit phase processes the FriUnsentCommitment
object in the following way:
cfg.fri_step_sizes[0] == 0
). (Note that this is mostly to make sure that the prover is following the protocol correctly, as the second layer is never skipped in this standard.)inner_layers
field and perform the following:last_layer_coefficients
with the channel.log_last_layer_degree_bound
, see the Configuration section): 2^cfg.log_last_layer_degree_bound == len(unsent_commitment.last_layer_coefficients)
.FRI queries are generated once, and then refined through each reduction of the FRI protocol. The number of queries that is pseudo-randomly generated is based on configuration.
Each FRI query is composed of the following fields:
index
: the index of the query in the layer's evaluations. Note that this value needs to be shifted before being used as a path in a Merkle tree commitment.y_value
: the evaluation of the layer's polynomial at the queried point.x_inv_value
: the inverse of the point at which the layer's polynomial is evaluated. This value is derived from the index
as explained in the next subsection.struct FriLayerQuery {
index: felt252,
y_value: felt252,
x_inv_value: felt252,
}
That is, we should have for each FRI query for the layer the following identity:
Or in terms of commitment, that the decommitment at path index
is y_value
.
The generation of each FRI query goes through the same process:
Finally, when all FRI queries have been generated, they are sorted in ascending order.
A query (a value within for the log-size of the evaluation domain) can be converted to an evaluation point in the following way. First, compute the bit-reversed exponent:
Then compute the element of the evaluation domain in the coset (with the generator of the evaluation domain):
TODO: explain why not just do
Finally, the expected evaluation can be computed using the API defined in the Verifying the first FRI layer section.
Besides the last layer, each layer verification of a query happens by:
We illustrate this in the following diagram, pretending that associated evaluations are not grouped under the same path in the Merkle tree commitment (although in practice they are).
To verify the last layer's query, as the last layer polynomial is received in clear, simply evaluate it at the queried point 1/fri_layer_query.x_inv_value
and check that it matches the expected evaluation fri_layer_query.y_value
.
Each query verification (except on the last layer) will produce queries for the next layer, which will expect specific evaluations.
The next queries are derived as:
index / coset_size
point^coset_size
where coset_size is 2, 4, 8, or 16 depending on the layer (but always 2 for the first layer).
Queries between layers verify that the next layer is computed correctly based on the current layer .
The next layer is either the direct next layer or a layer further away if the configuration allows layers to be skipped.
Specifically, each reduction is allowed to skip 0, 1, 2, or 3 layers (see the MAX_FRI_STEP
constant).
The FRI formula with no skipping is:
The FRI formula with 1 layer skipped with the generator of the 4-th roots of unity (such that ):
As you can see, this requires 4 evaluations of p_{i} at , , , .
The FRI formula with 2 layers skipped with the generator of the 8-th roots of unity (such that and ):
As you can see, this requires 8 evaluations of p_{i} at , , , , , , , .
The FRI formula with 3 layers skipped with the generator of the 16-th roots of unity (such that , , and ):
As you can see, this requires 16 evaluations of p_{i} at , , , , , , , , , , , , , , , .
TODO: reconcile with constants used for elements and inverses chosen in subgroups of order (the s)
In order to increase the cost of attacks on the protocol, a proof of work is added at the end of the commitment phase.
Given a 32-bit hash digest
and a difficulty target of proof_of_work_bits
, verify the 64-bit proof of work nonce
by doing the following:
init_hash = hash_n_bytes(0x0123456789abcded || digest || proof_of_work_bits)
(TODO: endianness)hash = hash_n_bytes(init_hash || nonce)
(TODO: endianness)hash
start with proof_of_work_bits
zeros (where proof_of_work_bits
is enforced to be between 20 and 50 as discussed in the General Configuration section).The FRI flow is split into four main functions. The only reason for doing this is that verification of FRI proofs can be computationally intensive, and users of this specification might want to split the verification of a FRI proof in multiple calls.
The four main functions are:
fri_commit
, which returns the commitment to every layers of the FRI proof.fri_verify_initial
, which returns the initial set of queries to verify the first reduction (Which is special as explained in the Notable Differences With Vanilla FRI section).fri_verify_step
, which takes a set of queries and returns another set of queries.fri_verify_final
, which takes the final set of queries and the last layer coefficients and returns the final result.To retain context, functions pass around two objects:
struct FriVerificationStateConstant {
// the number of layers in the FRI proof (including skipped layers) (TODO: not the first)
n_layers: u32,
// commitments to each layer (excluding the first, last, and any skipped layers)
commitment: Span<TableCommitment>,
// verifier challenges used to produce each (non-skipped) layer polynomial (except the first)
eval_points: Span<felt252>,
// the number of layers to skip for each reduction
step_sizes: Span<felt252>,
// the hash of the polynomial of the last layer
last_layer_coefficients_hash: felt252,
}
struct FriVerificationStateVariable {
// a counter representing the current layer being verified
iter: u32,
// the FRI queries for each (non-skipped) layer
queries: Span<FriLayerQuery>,
}
We give more detail to each function below.
fri_commit(channel)
.
n_queries
queries and the associated queried points points
in the eval_domain_size
according to the Generating Queries section.points
using the external dependency (see External Dependencies section), producing values
.FriDecommitment { values, points }
.fri_verify_initial(queries, fri_commitment, decommitment)
. Takes the FRI queries, the FRI commitments (each layer's committed polynomial), as well as the evaluation points and their associated evaluations of the first layer (in decommitment
).
decommitment
.FriLayerQuery { index, y_value, x_inv_value: 3 / x_value }
for each x_value
and y_value
given in the decommitment
. (This is a correction that will help achieve the differences in subsequent layers outlined in Notable Differences With Vanilla FRI).(
FriVerificationStateConstant {
n_layers: config.n_layers - 1,
commitment: fri_commitment.inner_layers, // the commitments
eval_points: fri_commitment.eval_points, // the challenges
step_sizes: config.fri_step_sizes[1:], // the number of reduction at each steps
last_layer_coefficients_hash: hash_array(last_layer_coefficients),
},
FriVerificationStateVariable { iter: 0, queries: fri_queries } // the initial queries
)
fri_verify_step(stateConstant, stateVariable, witness, settings)
.
stateVariable.iter <= stateConstant.n_layers
.iter
counter.fri_verify_final(stateConstant, stateVariable, last_layer_coefficients)
.
iter == n_layers
).last_layer_coefficient
matches the hash contained in the state (TODO: only relevant if we created that hash in the first function).fn fri_verify_final(
stateConstant: FriVerificationStateConstant,
stateVariable: FriVerificationStateVariable,
last_layer_coefficients: Span<felt252>,
) -> (FriVerificationStateConstant, FriVerificationStateVariable) {
assert(stateVariable.iter == stateConstant.n_layers, 'Fri final called at wrong time');
assert(
hash_array(last_layer_coefficients) == stateConstant.last_layer_coefficients_hash,
'Invalid last_layer_coefficients'
);
verify_last_layer(stateVariable.queries, last_layer_coefficients);
(
stateConstant,
FriVerificationStateVariable { iter: stateVariable.iter + 1, queries: array![].span(), }
)
}
Refer to the reference implementation for test vectors.
The current way to compute the bit security is to compute the following formula:
n_queries * log_n_cosets + proof_of_work_bits
Where:
n_queries
is the number of queries generates log_n_cosets
is the log2 of the blow-up factorproof_of_work_bits
is the number of bits required for the proof of work (see the Proof of Work section).