Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 263 additions & 0 deletions in-progress/7346-batch-proving-circuits-and-l1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
| | |
| -------------------- | ------------------------------------------------------------------------------------------------ |
| Issue | [Batch proving in Circuits and L1](https://github.com/AztecProtocol/aztec3-packages/issues/7346) |
| Owners | @spalladino |
| Approvers | @LeilaWang @LHerskind @iAmMichaelConnor |
| Target Approval Date | |

## Summary

With the separation of sequencers and provers, we now want to submit root rollup proofs that encompass multiple blocks. This requires changes in the rollup circuit topology as well as the L1 Rollup contract.

## Circuits

Let's first review the responsibilities of the current merge and root rollup circuits.

### Merge Rollup

The merge rollup circuit has the following inputs:

```rust
struct MergeRollupInputs {
previous_rollup_data: [{
public_inputs: BaseOrMergeRollupPublicInputs
nested_proof: NestedProof,
}; 2]
}
```

The circuit then performs the following checks and computations:

- Recursively verifies the left and right inputs
- Checks that the tree is greedily filled from left to right
- Checks that constants from left and right match
- Checks that the end state from left matches the start state from right (ie they follow from each other)
- Outputs the start of left and end of right
- Hashes together or sums up any accumulated fields (tx count, effects hashes, accumulated fees, etc)
- Propagates constants

And outputs:

```rust
struct BaseOrMergeRollupPublicInputs {
constants: {
previous_archive: TreeSnapshot,
global_variables: { block_number, timestamp, ... }
},
start: PartialStateReference,
end: PartialStateReference,
txs_effects_hash: Field,
out_hash: Field,
accumulated_fees: Field
num_txs: Field,
}
```

### Root rollup

The root rollup takes the same inputs as merge rollup, plus fields related to L1-to-L2 messaging, and related to updating the archive tree with the new block root.

```rust
struct RootRollupInputs {
previous_rollup_data : [PreviousRollupData; 2],

l1_to_l2_roots: RootRollupParityInput,
l1_to_l2_messages : [Field; NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP],
l1_to_l2_message_subtree_sibling_path : [Field; L1_TO_L2_MSG_SUBTREE_SIBLING_PATH_LENGTH],
start_l1_to_l2_message_tree_snapshot : AppendOnlyTreeSnapshot,

start_archive_snapshot : AppendOnlyTreeSnapshot,
new_archive_sibling_path : [Field; ARCHIVE_HEIGHT],
}
```

It performs the same checks as the merge circuit, plus:

- Creates a new L1-to-L2 tree snapshot
- Creates the new block header, which includes the previous archive tree root
- Updates the archive tree with the new block hash

```rust
struct RootRollupPublicInputs {
archive: AppendOnlyTreeSnapshot,
header: {
previous_archive: AppendOnlyTreeSnapshot,
content_commitment: ContentCommitment,
state: StateReference,
global_variables: GlobalVariables,
total_fees: Field
}
}
```

### New rollup structure

We propose changing the current rollup structure introducing two new circuits, a **block root rollup** and a **block merge rollup** circuit. The block root rollup circuit acts exactly the same as today's root rollup, grouping multiple base rollup into a tree via merge rollups until it produces a block. The block merge rollup circuits would then merge multiple blocks into a tree, until it reaches a new root rollup that proves a block range.

The tree levels, from top to bottom, would then be:

- Root
- Block merge
- Block root
- Merge
- Base

### Block root rollup

The block root rollup circuit is the same as today's root rollup circuit, but with its public inputs tweaked so it matches the public inputs from the block merge rollup as well, in the same way as today the base rollup public inputs are tweaked so they match the ones from the merge rollup.

```rust
struct BlockRootOrBlockMergePublicInputs {
previous_archive: AppendOnlyTreeSnapshot, // Archive tree root immediately before this block
new_archive: AppendOnlyTreeSnapshot, // Archive tree root after adding this block
previous_block_hash: Field, // Identifier of the previous block
end_block_hash: Field, // Identifier of the current block
out_hash: Field, // Merkle root of the L2-to-L1 messages in the block
start_global_variables: GlobalVariables, // Global variables for this block
end_global_variables: GlobalVariables, // Global variables for this block
fees: [{ recipient: Address, value: Field }; 32], // Single element equal to global_variables.coinbase and total_fees for the block
}
```

### Block merge rollup

The block merge rollup circuit, following the same line of the merge circuit, would take two `BlockRootOrBlockMergePublicInputs` and merge them together:

```rust
struct BlockMergeInputs {
previous_rollup_data: [{
public_inputs: BlockRootOrBlockMergePublicInputs
nested_proof: NestedProof,
}; 2]
}
```

Note that the semantics of the `BlockRootOrBlockMergePublicInputs` are now generalized to a block range:

```rust
struct BlockRootOrBlockMergePublicInputs {
previous_archive: AppendOnlyTreeSnapshot, // Archive tree root immediately before this block range
new_archive: AppendOnlyTreeSnapshot, // Archive tree root after adding this block range
out_hash: Field, // Merkle node of the L2-to-L1 messages merkle roots in the block range
previous_block_hash: Field, // Identifier of the previous block before the range
end_block_hash: Field, // Identifier of the last block in the range
start_global_variables: GlobalVariables, // Global variables for the first block in the range
end_global_variables: GlobalVariables, // Global variables for the last block in the range
fees: [{ recipient: Address, value: Field }; 32] // Concatenation of all coinbase and fees for the block range
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, this would be at most one entry per block in the tree. If two blocks were submitted by the same sequencer (same coinbase address) then presumably the values would get added together. So at the root level we can only have at most 32 fee recipients for 32 different blocks?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that's correct!

}
```

This circuit then performs the following checks and computations:

- Recursively verifies the left and right inputs
- Checks that `right.previous_archive` equals `left.new_archive`
- Checks that `right.previous_block_hash` equals `left.end_block_hash`
- Checks that `right.start_global_variables` follow from `left.end_global_variables`
- Concatenates and outputs the `fees` from both inputs
- Outputs `sha256(left.out_hash, right.out_hash)` as its own `out_hash`
- Outputs `previous_archive`, `start_global_variables`, and `previous_block_hash` from `left`
- Outputs `new_archive`, `end_global_variables`, and `end_block_hash` from `right`

Note that we say that the global variables in `right` "follow" the ones in `left` if:

- `left.chain_id == right.chain_id`
- `left.version == right.version`
- `left.block_number + 1 == right.block_number`
- `left.timestamp < right.timestamp`
- `coinbase`, `fee_recipient`, and `gas_fees` are not constrained (though `gas_fees` may be in a 1559-like world)

### Root rollup

The new root rollup circuit then takes two `BlockRootOrBlockMergePublicInputs`, performs the same checks as the block merge rollup, but outputs a subset of the public inputs, to make L1 verification cheaper:

```rust
struct RootRollupPublicInputs {
previous_archive: Field,
end_archive: Field,
end_block_hash: Field,
end_timestamp: Field,
end_block_number: Field,
out_hash: Field,
fees: [{ recipient: Address, value: Field }; 32]
}
```

### Empty block root rollup

Since we no longer submit a proof per block, and thanks to @MirandaWood we now have wonky rollups, we no longer need to fill a block with "empty" txs. This means we can discard the empty private kernel circuit.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this understanding align with what's written here: https://hackmd.io/@aztec-network/ByROkLKIC#Blocks-of-1-transaction ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That hackmd assumed we needed to create one proof per block, which is no longer the case, so we can replace "empty txs" with "empty blocks" in the superblock (name tbd!) proof.


However, we still need to be able to represent an empty block. We can do this by introducing an empty block root rollup circuit, which outputs a `BlockRootOrBlockMergePublicInputs` which does not consume any merge rollups, and the end state just equals the start state. Note that we may still need an empty nested circuit to fill in the nested proof.

## L1 Rollup Contract

The Rollup contract today has a main entrypoint `process(header, archive, aggregationObject, proof)`. We propose breaking this method into two:

```solidity
contract Rollup {
process(header, archive);
submitProof(publicInputs, aggregationObject, proof);
}
```

### State

To track both the proven and unproven chains, we add the following state variables to the contract:

```diff
contract Rollup {
bytes32 lastArchiveTreeRoot;
uint256 lastBlockTimestamp;
+ bytes32 verifiedArchiveTreeRoot;
+ uint256 verifiedBlockTimestamp;
}
```

The `last*` fields are updated every time a new block is uploaded, while the `verified*` ones are updated when a proof is uploaded. In the event of a rollback due to failure on proof submission, the `last*` fields are overwritten with the contents of `verified*`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we need another set of variables to track in-flight proving, otherwise publishing the 33rd block would overwrite the last* variables in the rollup which would fail proof validation.

Probably something to think about for proving coordination and having just a verified/last set of variables is enough.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

otherwise publishing the 33rd block would overwrite the last* variables in the rollup which would fail proof validation

Agree! That's why I suggested moving the "prove that this block root is in the current archive tree" to Solidity instead of the root rollup circuit. The proof would have a "last block proven", and the submitter would include a merkle path from it to the current lastArchiveTreeRoot. If that root changes, then the submitter needs to generate a new merkle path instead of rebuilding the entire root proof.

To further mitigate against this race condition, we could keep the last N archive tree roots instead of just the latest.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A fairly simple approach that I think I would take for the first version at least, would be to simple store a "list" of the archive values instead of just the tips.

This could be as:

mapping(uint256 blockNumber => bytes32 archive) public archives;

Then you keep track of 2 indexes for how far we have added blocks, and how far we have proven.

This would also allow providing a proof that is not the "next", e.g., to avoid the issue where someone is delaying their proof to limit the time you have to interact. We are looking to add some of this in AztecProtocol/aztec-packages#7614.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@LHerskind sounds good as a first approach, given that L1 gas is free!


### Process

Today the `process` method does the following:

1. Validate the new block header
2. Update the `lastArchiveTreeRoot` and `lastBlockTimestamp` in the contract storage
3. Consume L1-to-L2 messages from the Inbox
4. Emit an `L2BlockProcessed` event with the block number
5. Test data availability against the availability oracle
6. Verify the root rollup proof
7. Insert L2-to-L1 messages into the Outbox using the `out_hash` and block number
8. Pay out `total_fees` to `coinbase` in the L1 gas token

The first four items can keep being carried out by the `process` method for each new block that gets submitted. Proof verification, L2-to-L1 messages, and fee payment messages are moved to `submitProof`. Data availability needs more clarity based the interaction between the blob circuits and the point evaluation precompile, but it may be moved entirely to `submitProof`.

As for consuming L1-to-L2 messages, note that these cannot be fully deleted from the Inbox, since we need to be able to rollback unproven blocks in the event of a missing proof, which requires being able to consume those messages again on the new chain.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L1 to L2 messages don't need to be deleted from the inbox, they are not really deleted there, just progressing what tree we are reading. It still needs to perform some action at the "submitBlock", but we can get this fairly easily AztecProtocol/aztec-packages#7592


Last, we should rename `process` to something more descriptive, such as `submitBlock`.

### Submit proof

This method receives the root rollup public inputs, plus the aggregation object and proof to verify. Its responsibilities are the ones removed from `process`:

1. Verify the root rollup proof
2. Insert L2-to-L1 messages into the Outbox using the `public_inputs.out_hash` and the _last block number in the range_
3. Pay out `value` to `recipient` in the L1 gas token for each pair in the `public_inputs.fees` array

In addition, this method:

1. Checks that the `public_inputs.previous_archive` matches the current `verifiedArchiveTreeRoot` in the L1 contract (ie that the block range proven follows immediately from the previous proven block range).
2. Proves that the `end_block_hash` is in the `lastArchiveTreeRoot` (ie that the last block in the range proven is actually part of the pending chain) via a Merkle membership proof.
3. Updates the `verifiedArchiveTreeRoot` and `verifiedBlockTimestamp` fields in the L1 contract.
4. Emits an `L2ProofVerified` event with the block number.

## Discussions

### Binary vs fixed-size block merge rollup circuit

Assuming we implement a proving coordination mechanism where the block ranges proven are of fixed size (eg 32), then we could have a single circuit that directly consumes the 32 block root rollup proofs and outputs the public inputs expected by L1. This could be more efficient than constructing a tree of block root rollup proofs. On the other hand, it is not parallelizable, inflexible if we wanted a dynamic block range size, and must wait until all block root rollup proofs are available to start proving.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd vote for maximum parallelisation :P


### Batched vs optimistic vs pull fee payments

In the model above, fee payments are executed in-batch at the time the proof is submitted. Assuming the L1 payment token includes a `transferBatch` method, the cost of 32 payments can be brought down to about 160k gas (5000 x 32), which is manageable. However, it imposes a max size on the number of blocks it can be proven on a single batch, and 160k is still a significant number.

Alternatively, we could keep fee payments as part of the `Rollup.process` method, so that a sequencer is payed the moment they submit a block, even if it doesn't get proven. Depending on how we implement unhappy paths in block building, we could optimistically pay fees on submission, and recoup them as part of a slashing mechanism. This would also simplify the block rollup circuits, as we would not need to construct an array of payments. Either way, it could be a good idea to start with this approach initially as it requires the least engineering effort.

Another option is to implement pull payments via merkle proofs, where each proof submits not the explicit list of payments but a merkle root of them, which gets stored in the contract. Sequencers then need to submit a merkle membership proof to claim their corresponding fees. This gives us the flexibility of being able to cram as many blocks in a proof as we want to, but adds significant complexity and gas cost to sequencers.