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

Overview

We briefly give an overview of the FRI protocol, before specifying how it is used in the StarkNet protocol.

FRI

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 0. 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 2.

If the reductions are "correct", and it takes n 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 2n.

In order to ensure that the reductions are correct, two mechanisms are used:

  1. First, an interactive protocol is performed with a verifier who helps randomize the halving of polynomials. In each round the prover commits to a "layer" polynomial.
  2. Second, as commitments are not algebraic objects (as FRI works with hash-based commitments), the verifier query them in multiple points to verify that an output polynomial is consistent with its input polynomial and a random challenge. (Intuitively, the more queries, the more secure the protocol.)

Setup

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

Reduction

folding

A reduction in the FRI protocol is obtained by interpreting an input polynomial p as a polynomial of degree 2n and splitting it into two polynomials g and h of degree n such that p(x)=g(x2)+xh(x2).

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 g(x)+ζh(x) of degree n:

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 7 is reduced to a polynomial of degree 0 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

Queries

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 p0(x)=g0(x2)+xh0(x2) and two of its evaluations at some points v and v, we can see that the verifier can recover the two halves by computing:

  • g0(v2)=p0(v)+p0(v)2
  • h0(v2)=p0(v)p0(v)2v

Then, the verifier can compute the next layer's evaluation at v2 as:

p1(v2)=g0(v2)+ζ0h0(v2)

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 v2 of the next layer's polynomial p1, we can simply query the evaluation of p1(v2) 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

Skipping FRI layers

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 p1), then the verifier would not be able to:

  • query p1(v2) to verify that layer
  • query p1(v2) to continue the protocol and get g1,h1

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 p1(v2). Remember, we needed p0(v) and p0(v) to compute g0(v2) and h0(v2). But to compute g0(v2) and h0(v2), we need the quadratic residues of v2, that is w, such that w2=v2, so that we can compute g0(v2) and h0(v2) from p0(w) and p0(w).

We can easily compute them by using τ (tau), the generator of the subgroup of order 4:

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)

Last Layer Optimization

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 p2 (h2 and g2) 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

FRI-PCS

Given a polynomial f and an evaluation point a, a prover who wants to prove that f(a)=b can prove the related statement for some quotient polynomial q of degree deg(f)1:

f(x)bxa=q(x)

(This is because if f(a)=b then a should be a root of f(x)b and thus the polynomial can be factored in this way.)

Specifically, FRI-PCS proves that they can produce such a (commitment to a) polynomial q.

Aggregating Multiple FRI Proofs

To prove that two polynomials a and b exist and are of degree at most d, a prover simply shows using FRI that a random linear combination of a and b exists and is of degree at most d.

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 a should be of degree at most d but the polynomial should be of degree at most d+3 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.)

Notable Differences With Vanilla FRI

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 p0(v) and p0(v) and computes the next layer's evaluation at v2 as

pi+1(v2)=pi(v)+pi(v)2+ζipi(v)pi(v)2v

which is equivalent to

pi+1(v2)=gi(v2)+ζihi(v2)

where

pi(x)=gi(x2)+xhi(x2)

The first difference in this specification is that, assuming no skipped layers, the folded polynomial is multiplied by 2:

pi+1(x)=2(gi(x)+ζi·hi(x))

This means that the verifier has to modify their queries slightly by not dividing by 2:

pi+1(v2)=pi(v)+pi(v)+ζi·pi(v)pi(v)v

The second difference is that while the evaluations of the first layer p0 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:

p1(x)=2(g0(9x2)+ζ0·3·h0(9x2))

Notice that the prover has also multiplied the right term with 3. This is a minor change that helps with how the verifier code is structured.

This means that the verifier computes the queries on p1(x) at points on the original subgroup. So the queries of the first layer are produced using v=v/3 (assuming no skipped layers).

p1((v2)=p0(v)+p0(v)+ζ0·p0(v)p0(v)v

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.

External Dependencies

In this section we list all the dependencies and interfaces this standard relies on.

Hash Functions

We rely on two type of hash functions:

Channel

See the Channel specification for details.

Verifying the first FRI layer

As part of the protocol, the prover must provide a number of evaluations of the first layer polynomial p0 (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.

Constants

We use the following constants throughout the protocol.

Protocol constants

STARKNET_PRIME = 3618502788666131213697322783095070105623107215331596699973092056135872020481. The Starknet prime (2251+17·2192+1).

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.

FRI constants

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 215+15=230.

Step Generators And Inverses

As explained in the overview, skipped layers must involve the use of elements of the subgroups of order 2i for i 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):

So here, for example, OMEGA_8 is 1/ω8 where ω8 is the generator of the subgroup of order 8 that we later use in the Verify A Layer's Query section.

Configuration

General configuration

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.

FRI configuration

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:

TODO: move these validation steps in the description of the fields above

Domains and Commitments

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 2nt for some nt, such that it can include all the constraints of an AIR constraint system. A generator for the trace domain can be found as ωt=3(p1)/nt (since ωtnt=1)

The blown-up trace domain, which is chosen as a subgroup of a power of two 2ne that encompasses the trace domain (i.e. et). 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 ωe=3(p1)/ne.

The evaluation domain, This is a coset of the blown-up domain, computed using the generator of the main group: {3·ωei|i[[0,ne]]}.

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).

Protocol

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:

  1. Commit phase
  2. Query phase

We go through each of the phases in the next two subsections.

Commit Phase

The commit phase processes the FriUnsentCommitment object in the following way:

  1. Enforce that the first layer has a step size of 0 (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.)
  2. Go through each commitment in order in the inner_layers field and perform the following:
  3. Absorb the commitment using the channel.
  4. Produce a random challenge.
  5. Absorb the last_layer_coefficients with the channel.
  6. Check that the last layer's degree is correct (according to the configuration log_last_layer_degree_bound, see the Configuration section): 2^cfg.log_last_layer_degree_bound == len(unsent_commitment.last_layer_coefficients).
  7. return all the random challenges.

Query Phase

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:

struct FriLayerQuery {
    index: felt252,
    y_value: felt252,
    x_inv_value: felt252,
}

That is, we should have for each FRI query for the layer i+1 the following identity:

pi+1(1/x_inv_value)=y_value

Or in terms of commitment, that the decommitment at path index is y_value.

Generating The First Queries

The generation of each FRI query goes through the same process:

  • Sample a random challenge from the channel.
  • Truncate the challenge to obtain the lower 128-bit chunk.
  • Reduce it modulo the size of the evaluation domain.

Finally, when all FRI queries have been generated, they are sorted in ascending order.

A query q (a value within [0,2ne] for ne the log-size of the evaluation domain) can be converted to an evaluation point in the following way. First, compute the bit-reversed exponent:

q=bit_reverse(q·264ne)

Then compute the element of the evaluation domain in the coset (with ωe the generator of the evaluation domain):

3·ωeq

TODO: explain why not just do 3·ωeq

Finally, the expected evaluation can be computed using the API defined in the Verifying the first FRI layer section.

Verify A Layer's Query

Besides the last layer, each layer verification of a query happens by:

  1. verifying the query on the current layer. This is done by effectively decommitting a layer's query following the Merkle Tree Polynomial Commitment specification.
  2. computing the next query as explained below.

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).

a FRI query

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: index / coset_size
  • point: point^coset_size
  • value: see FRI formula below

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 pi+j is computed correctly based on the current layer pi. The next layer is either the direct next layer pi+1 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:

  • given a layer evaluations at ±v, a query without skipping layers work this way:
  • we can compute the next layer's expected evaluation at v2 by computing pi+1(v2)=pi(v)+pi(v)2+ζi·pi(v)pi(v)2v
  • we can then ask the prover to open the next layer's polynomial at that point and verify that it matches

The FRI formula with 1 layer skipped with ω4 the generator of the 4-th roots of unity (such that ω42=1):

  • pi+1(v2)=pi(v)+pi(v)2+ζi·pi(v)pi(v)2v
  • pi+1(v2)=pi(ω4v)+pi(ω4v)2+ζi·pi(v)pi(ω4v)2·ω4·v
  • pi+2(v4)=pi+1(v2)+pi+1(v2)2+ζi2·pi(v2)pi(v2)2·v2

As you can see, this requires 4 evaluations of p_{i} at v, v, ω4v, ω4v.

The FRI formula with 2 layers skipped with ω8 the generator of the 8-th roots of unity (such that ω82=ω4 and ω84=1):

  • pi+1(v2)=pi(v)+pi(v)2+ζi·pi(v)pi(v)2v
  • pi+1(v2)=pi(ω4v)+pi(ω4v)2+ζi·pi(v)pi(ω4v)2·ω4·v
  • pi+1(ω4v2)=pi(ω8v)+pi(ω8v)2+ζi·pi(ω8v)pi(ω8v)2ω8v
  • pi+1(ω4v2)=pi(ω83v)+pi(ω83v)2+ζi·pi(ω83v)pi(ω83v)2·ω83·v
  • pi+2(v4)=pi+1(v2)+pi+1(v2)2+ζi2·pi+1(v2)pi+1(v2)2·v2
  • pi+2(v4)=pi+1(ω4v2)+pi+1(ω4v2)2+ζi2·pi+1(ω4v2)pi+1(ω4v2)2·ω4v2
  • pi+3(v8)=pi+2(v4)+pi+2(v4)2+ζi4·pi+2(v4)pi+2(v4)2·v4

As you can see, this requires 8 evaluations of p_{i} at v, v, ω4v, ω4v, ω8v, ω8v, ω83v, ω83v.

The FRI formula with 3 layers skipped with ω16 the generator of the 16-th roots of unity (such that ω162=ω8, ω164=ω4, and ω168=1):

  • pi+1(v2)=pi(v)+pi(v)2+ζi·pi(v)pi(v)2v
  • pi+1(v2)=pi(ω4v)+pi(ω4v)2+ζi·pi(v)pi(ω4v)2·ω4·v
  • pi+1(ω4v2)=pi(ω8v)+pi(ω8v)2+ζi·pi(ω8v)pi(ω8v)2ω8v
  • pi+1(ω4v2)=pi(ω83v)+pi(ω83v)2+ζi·pi(ω83v)pi(ω83v)2·ω83·v
  • pi+1(ω8v2)=pi(ω16v)+pi(ω16v)2+ζi·pi(ω16v)pi(ω16v)2ω16v
  • pi+1(ω8v2)=pi(ω165v)+pi(ω165v)2+ζi·pi(ω165v)pi(ω165v)2ω165v
  • pi+1(ω83v2)=pi(ω163v)+pi(ω163v)2+ζi·pi(ω163v)pi(ω163v)2ω163v
  • pi+1(ω83v2)=pi(ω167v)+pi(ω167v)2+ζi·pi(ω167v)pi(ω167v)2ω167v
  • pi+2(v4)=pi+1(v2)+pi+1(v2)2+ζi2·pi+1(v2)pi+1(v2)2·v2
  • pi+2(v4)=pi+1(ω4v2)+pi+1(ω4v2)2+ζi2·pi+1(ω4v2)pi+1(ω4v2)2·ω4v2
  • pi+2(ω4v4)=pi+1(ω8v2)+pi+1(ω8v2)2+ζi2·pi+1(ω8v2)pi+1(ω8v2)2·ω8·v2
  • pi+2(ω4v4)=pi+1(ω83v2)+pi+1(ω83v2)2+ζi2·pi+1(ω83v2)pi+1(ω83v2)2·ω83v2
  • pi+3(v8)=pi+2(v4)+pi+2(v4)2+ζi4·pi+2(v4)pi+2(v4)2·v4
  • pi+3(v8)=pi+2(ω4v4)+pi+2(ω4v4)2+ζi4·pi+2(ω4v4)pi+2(ω4v4)2·ω4v4
  • pi+4(v16)=pi+3(v8)+pi+3(v8)2+ζi8·pi+3(v8)pi+3(v8)2·v8

As you can see, this requires 16 evaluations of p_{i} at v, v, ω4v, ω4v, ω8v, ω8v, ω83v, ω83v, ω16v, ω16v, ω163v, ω163v, ω165v, ω165v, ω7v, ω7v.

TODO: reconcile with constants used for elements and inverses chosen in subgroups of order 2i (the ωs)

Proof of work

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:

  1. Produce a init_hash = hash_n_bytes(0x0123456789abcded || digest || proof_of_work_bits) (TODO: endianness)
  2. Produce a hash = hash_n_bytes(init_hash || nonce) (TODO: endianness)
  3. Enforce that the 128-bit high bits of 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).

Full Protocol

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:

  1. fri_commit, which returns the commitment to every layers of the FRI proof.
  2. 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).
  3. fri_verify_step, which takes a set of queries and returns another set of queries.
  4. 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).

  1. Take a channel with a prologue (See the Channel section). A prologue contains any context relevant to this proof.
  2. Produce the FRI commits according to the Commit Phase section.
  3. Produce the proof of work according to the Proof of Work section.
  4. Generate n_queries queries and the associated queried points points in the eval_domain_size according to the Generating Queries section.
  5. Evaluate the first layer at the queried points using the external dependency (see External Dependencies section), producing values.
  6. Produce the fri_decommitment as 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).

  1. Enforce that for each query there is a matching derived evaluation point and evaluation at that point on the first layer contained in the given decommitment.
  2. Enforce that last layer has the right number of coefficients as expected by the FRI configuration (see the FRI Configuration section).
  3. Compute the first layer of queries as 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).
  4. Initialize and return the two state objects
(
    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).

  1. Enforce that stateVariable.iter <= stateConstant.n_layers.
  2. Verify the queried layer and compute the next query following the Verify A Layer's Query section.
  3. Increment the iter counter.
  4. Return the next queries and the counter.

fri_verify_final(stateConstant, stateVariable, last_layer_coefficients).

  1. Enforce that the counter has reached the last layer from the constants (iter == n_layers).
  2. Enforce that the last_layer_coefficient matches the hash contained in the state (TODO: only relevant if we created that hash in the first function).
  3. Manually evaluate the last layer's polynomial at every query and check that it matches the expected evaluations.
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(), }
    )
}

Test Vectors

Refer to the reference implementation for test vectors.

Security Considerations

The current way to compute the bit security is to compute the following formula:

n_queries * log_n_cosets + proof_of_work_bits

Where: