Aggregation allows larger amounts of data to be verified on-chain using fewer proofs.
Currently, chunks (a list of continuous blocks) are first aggregated into a batch, then multiple batches are aggregated using a recursive scheme into a bundle.
The bundle
is the current apex entity that will be verified on-chain.
param | meaning |
---|---|
k | number of valid chunks |
n | max number of chunks per batch (hard-coded) |
t | number of rounds for the final hash |
A chunk is a list of L2 blocks
and will be proven by the ChunkCircuit
(this is in fact the ZkEVM SuperCircuit
). It consists of 5 hashes:
- state root before this chunk
- state root after this chunk
- the withdraw root of this chunk
- the data hash of this chunk
- the tx data hash of this chunk
Those 5 hashes are obtained from the caller.
The chunk's public input hash is
chunk_pi_hash := keccak(chain_id || prev_state_root || post_state_root || withdraw_root || chunk_data_hash || chunk_txdata_hash)
A list of continuous chunks
c_i.post_state_root == c_{i+1}.prev_state_root
for
A padded chunk is a chunk that repeats the last valid chunk. It is used for padding.
If
- state root before this chunk:
c_{k}.prev_state_root
- state root after this chunk:
c_{k}.post_state_root
- the withdraw root of this chunk:
c_{k}.withdraw_root
- the data hash of this chunk:
c_{k}.data_hash
- the tx data hash of this chunk:
c_{k}.txdata_hash
A batch is a list of continuous chunks
of size k
that will be aggregated using the BatchCircuit
. If the input chunks' size k
is less than n
, we pad the input with (n-k)
chunks identical to chunk[k]
. The batch is represented by the preimage fields to the batch_hash
, which is constructed as:
batchHash := keccak256(version || batch_index || l1_message_popped || total_l1_message_popped || batch_data_hash || versioned_hash || parent_batch_hash || last_block_timestamp || z || y)
All preimage fields' values are provided to the batch through the BatchHeader
struct, so it can correctly construct the hash state transition from parent_batch_hash
to batch_hash
(for current batch).
Note that there are also implicit constraints between state roots before/after batch and the state roots of the chunks it has aggregated:
prev_state_root := c_0.prev_state_root
post_state_root := c_k.post_state_root
The current schema for batch header is:
Field | Bytes | Type | Index | Comments |
---|---|---|---|---|
version | 1 | uint8 | 0 | The batch version |
batchIndex | 8 | uint64 | 1 | The index of the batch |
l1MessagePopped | 8 | uint64 | 9 | Number of L1 messages popped in the batch |
totalL1MessagePopped | 8 | uint64 | 17 | Number of total L1 messages popped after the batch |
dataHash | 32 | bytes32 | 25 | The data hash of the batch |
blobVersionedHash | 32 | bytes32 | 57 | The versioned hash of the blob with this batch’s data |
parentBatchHash | 32 | bytes32 | 89 | The parent batch hash |
lastBlockTimestamp | 8 | uint64 | 121 | The timestamp of the last block in this batch |
blobDataProof | 64 | bytes64 | 129 | The blob data proof: z (32), y (32) |
A list of continuous batches
b_i.batch_hash == b_{i+1}.parent_batch_hash AND
b_i.post_state_root == b_{i+1}.prev_state_root
for
A bundle is a list of continuous batches
that will be aggregated recursively using the RecursionCircuit
. The bundle is the current apex entity whose proof will be verified on-chain.
Circuit proving the relationship for a chunk is the zkEVM circuit. It will go through 2 layers of compression circuit, and becomes a snark struct. We do not list its details here. Abstractly, a snark circuit has the following properties:
- it takes 44 elements as public inputs
- 12 from accumulators
- 32 from public input hash
We want to aggregate k
snarks, each from a valid chunk. We generate (n-k)
padded chunks (by repeating the last non-padding chunk), and obtain a total of n
snarks.
Additionally, the batch circuit has to ascertain the correct hash transition from the parent batch.
There are several configuration subcomponents for batch circuit.
- FpConfig; used for snark aggregation.
- KeccakConfig: used to build keccak table.
- RlcConfig: used to compute RLC of hash inputs.
- BlobDataConfig: used for representing the zstd-encoded form of batch data, with
4096 * 31
rows. Each row is a byte value. An EIP-4844 blob consists of4096 * 32
bytes, where we set the most-significant byte in each 32-bytes chunk as0
to guarantee that each 32-bytes chunk is a valid BLS12-381 scalar field element. - BatchDataConfig: used for representing the raw batch bytes, effectively constructing the random challenge point
z
for the KZG opening proof. - DecoderConfig: implements an in-circuit zstd-decoder that decodes blob data into batch data
- BarycentricEvaluationConfig: used for evaluating the interpolated blob polynomial at an arbitrary challenge point
z
, where bothz
and the evaluationy
are included in theBatchHeader
.
The public input of the batch circuit consists of
- 12 elements from accumulator
- 2 elements of
parent_state_root
(split by hi/lo) - 2 elements of
parent_batch_hash
- 2 elements of
current_state_root
- 2 elements of
current_batch_hash
- 1 element of
chain_id
- 2 elements of
current_withdraw_root
Note that parent_state_root
is the same as chunk[0].prev_state_root
and current_state_root
is the same as chunk[k].post_state_root
. When these chunk fields are assigned into keccak preimages, their cells are constrained against the public input to ensure equality. If any public input appears in the preimage of the batch_hash
, their corresponding assigned preimage cells will be equality constrained as well.
For snarks
-
batch_data_hash digest is reused for batch hash. Static.
-
batch_data_hash and chunk[i].pi_hash use a same chunk[i].data_hash when chunk[i] is not padded
for i in 1 ... n
chunk_pi_hash := keccak(chain_id || prev_state_root || post_state_root || withdraw_root || chunk_data_hash || chunk_txdata_hash)
This is done by computing the RLCs of chunk[i]'s data_hash for i=0..k
, and then check the RLC matches the one from the keccak table.
- chunks are continuous when they are not padded: they are linked via the state roots.
for i in 1 ... k-1
c_i.post_state_root == c_{i+1}.prev_state_root
- All the chunks use the same chain id. Static.
for i in 1 ... n
batch.chain_id == chunk[i].chain_id
- The last
(n-k)
chunk[i] are padded chunks
for i in 1 ... n:
if is_padding:
chunk[i]'s chunk_pi_hash_rlc_cells == chunk[i-1].chunk_pi_hash_rlc_cells
This is done via comparing the data_rlc
of chunk_{i-1}
and chunk_{i}
.
6. the hash input length is correct
- hashes[0] has 193 bytes (
batch_hash
preimage) - hashes[1..N_SNARKS+1] has 168 bytes input (
chunk_pi_hash
preimages) - batch's data_hash length is 32 * number_of_valid_snarks (
batch_data_hash
preimage)
Our keccak table uses 300
rows. When the number of round is less than
The only hash that uses a dynamic number of rounds is the last hash.
Suppose we target for MAX_AGG_SNARK = 10
. Then, the last hash function will take no more than 32 * 10 /136 = 3
rounds.
We also know in the circuit if a chunk is an empty one or not. This is given by a flag is_padding
.
For the input of the final data hash
- we extract
32 * MAX_AGG_SNARK
number of cells (static here) from the last hash. We then compute the RLC of those32 * MAX_AGG_SNARK
when the correspondingis_padding
is not set. We constrain this RLC match thedata_rlc
from the keccak table.
For the output of the final data hash
- we extract all three hash digest cells from the last 3 rounds. We then constraint that the actual data hash matches one of the three hash digest cells with proper flags defined as follows.
- if the num_of_valid_snarks <= 4, which only needs 1 keccak-f round. Therefore the batch's data hash (input, len, data_rlc, output_rlc) is in the first 300 keccak rows;
- else if the num_of_valid_snarks <= 8, which needs 2 keccak-f rounds. Therefore the batch's data hash (input, len, data_rlc, output_rlc) is in the 2nd 300 keccak rows;
- else the num_of_valid_snarks <= 12, which needs 3 keccak-f rounds. Therefore the batch's data hash (input, len, data_rlc, output_rlc) is in the 3rd 300 keccak rows;
#valid snarks | offset of data hash | flags |
---|---|---|
1,2,3,4 | 0 | 1, 0, 0 |
5,6,7,8 | 32 | 0, 1, 0 |
9,10 | 64 | 0, 0, 1 |
Additional checks for dummy chunk
- if
is_padding
fori
-th chunk, we constrainchunk[i]'s chunk_pi_hash_rlc_cells == chunk[i-1].chunk_pi_hash_rlc_cells
RecursionCircuit
aggregates AppCircuit
). It achieves this aggregation by repeatedly combine each AppCircuit
's SNARK with a SNARK generated from last round of aggregation (hence the name recursion
). In each round of recursion, the Recursion circuit verifies a SNARK from the AppCircuit
and its SNARK from the “previous” round. For the first round of aggregation, a dummy SNARK is generated to combine with the first AppCircuit
SNARK. Essentially, we have:
where accumulator_indices
, Recursion Circuit can merge AppCircuit
’s accumulator, in case AppCircuit
itself is also a Batch Circuit.
The AppCircuit
must follow a layout that RecursionCircuit
accepts. The layout is described in the StateTransition
trait which describes a data which can transition from prev state to current state, with methods clearly indicating the indices of accumulators, states (prev and post) and additional exported PI fields.
pub trait StateTransition: Sized {
type Input: Clone;
type Circuit: CircuitExt<Fr>;
// Describes the state transition
fn state_transition(&self, round: usize) -> Self::Input;
// Count of the fields used to represent state. The public input consists of twice
// this number as both the previous and current states are included in the public input.
fn num_transition_instance() -> usize
// Other counts of instance variables
fn num_additional_instance() -> usize
fn num_instance() -> usize
fn num_accumulator_instance() -> usize
// Location of accumulator, state variables and additional exported PIs
fn accumulator_indices() -> Vec<usize>
fn state_prev_indices() -> Vec<usize>
fn state_indices() -> Vec<usize>
fn additional_indices() -> Vec<usize>
fn propagate_indices() -> Vec<usize>
}
All parts of AppCircuit
is also put into the
`accumulator` | `preprocessed_digest` | `init_states` | `final_states` | `propagated_additional_states` | `additional_states` | `round`
-
accumulator
accumulates all of the accumulators from the$N$ $snark_{app}$ , all the accumulators exported from the$PI$ of these snarks (if there is any), and accumulators generated by the$N$ steps verification of snarks from recursion circuit. -
preprocessed_digest
represents the Recursion Circuit itself. There would be an unique value for every recursion circuit which can bundle (any number of) snarks from specifiedAppCircuit
-
init_states
represent the initial state$S_0$ . -
final_states
represent the final state, along with the exported$PI$ from$S_N$ . -
propagated_additional_states
represent PIs in app states which do not involved in state transition, however, the PIs in recursion circuit must be "propagated" into the corresponding PI in every app circuit being verified recursively. For example, the PI ofchainID
in each batch circuit must be same when they are verified together in the recursion circuit -
additional_states
represent the PIs in app state which do not involved in state transition, and only the PIs in the last app circuit for this part are "export" transparently. -
round
represents the number of batches being bundled recursively, i.e.$N$ .
To verify the AppCircuit
, and the snark of $(k-1){th}$ recursion circuit respectively. We named it $PI$, $PI{app}$ and
- if
$N > 0$ ,$PI(preprocessed_digest) = PI_{prev}(preprocessed_digest)$ : ensure the snark for “previous recursion circuit” is the same circuit of current one - if
$N > 0$ ,$PI(round) = PI_{prev}(round) + 1$ : ensure the round number is increment so the first snark from app circuit has round = 0 - if
$N > 0$ ,$PI(init_states) = PI_{prev}(init_states)$ , else$PI(init_states) = PI_{app}(init_states)$ : propagate the init state, and for first recursion, the init state part of PI is passed to app circuit -
$PI_{app}(final_states) = PI(final_states)$ : transparent pass the PIs infinal_states
to app circuit -
$PI_{app}(all_additional_states) = PI(all_additional_states)$ : ensure the PIs inpropagated_additional_states
andadditional_states
would be passed to app circuit's PI fields, which are being marked byadditional_indices
- if
$N > 0$ ,$PI_{app}(propagated_additional_states) = PI_{prev}(propagated_additional_states)$ : propagate the additional state being marked bypropagate_indices
-
$PI_{app}(init_states) = PI_{prev}(final_states)$ : the init state part of PI for app circuit must be “chained” with previous recursion round