mailwal

[ethresear.ch] [Layer 2] It's time to scale the trust: EIP-8288 on OP Stack

Harry Jeon
sm-stack
November 2
image

Summary

Rollups scaled by moving execution off the L1, but the authentication part of every transaction still goes to the L1. That makes data the likely bottleneck for L2 scaling in the PQ era, even with more blobs. PQ signatures are far larger than ECDSA, so if every L2 user switched to one, signatures would make up 99% of transaction data and the transfers that fit in 6 blobs would fall from about 6,600 to 155. ZK applications have it worse, since a single PQ proof usually does not fit in 3 blobs. Either way, the bottleneck is that the cost of checking trust grows with the number of users.

An L2 can cut this cost by adopting EIP-8288: signatures and proofs move out of transactions, and the statements of many blocks are bundled into one leanVM aggregate proof posted to the L1. A transaction authenticated with leanSPHINCS then compresses to 100 to 129 bytes, close to an ECDSA transaction.

A 32-core machine (AWS c7a.8xlarge) can prove up to 170 leanSPHINCS signatures per second on a 2-second chain. At that load, the 2,040 leanSPHINCS transactions arriving per L1 block fit in 2.8 to 3.3 blobs on average, calculated from the measured aggregate size and compression ratio. Without EIP-8288 aggregates, carrying the signatures in the transactions would take 79 blobs for the same transactions.

Implementation Link (GitHub)

1. The problem

Rollup scaling came from moving execution off the L1. The L1 never re-executes the state transition, however many transactions there are, and this is what gives today’s rollups tens of times the L1’s capacity. But L2 signature data still lands on the L1 with every transaction, and it has no structure to compress.

With today’s ECDSA signatures, signature data is already more than half of a transaction after compression. The PQ signatures needed to prepare for quantum computers are 10 to over 100 times larger than ECDSA.

ObjectSize
ECDSA signature (secp256k1)65 B
Falcon-512 signature~666 B
ML-DSA-44 signature2,420 B
leanSPHINCS signature + public key4,936 B + 32 B
SLH-DSA-128s signature7,856 B
leanVM leaf proof (ZK)415–430 KiB

An OP Stack channel uses 130,044 bytes of each blob. Changing only the signature in the same workload gives these capacities for a 6-blob channel.

TransactionCompressedOf which authentication6-blob channel
ECDSA signature118 B67 Babout 6,600
leanSPHINCS signature inline5,034 B4,983 B155

Users pay the L1 data fee on those bytes for every transaction, and the L2’s total throughput drops sharply.

Privacy applications on L2 are in a worse position, because hash-based STARK proofs are at the size of hundreds of kilobytes. ZK applications such as Railgun, Privacy Pools, or Privacy Boost use small proofs like Groth16 today, but once they move to hash-based proofs like leanVM’s for the PQ era, a single proof is over 400 KB. The EVM also has no precompile for hash-based signatures or binary-field STARKs, and verifying them in a contract goes far past a transaction’s gas budget, so they cannot be verified onchain either.

The point of a rollup is that what the L1 pays for execution does not grow with the number of transactions. In the PQ era the same has to hold for authentication and proof verification, across both transactions and applications. EIP-8288 is designed to solve this problem on the L1, but it is just as useful on an L2.

2. Design

EIP-8288 on the OP Stack

EIP-8288 is an L1 design for taking signatures and proofs out of blocks. It adds a dependency frame to EIP-8141 frame transactions, and that frame declares a list of (scheme, data_hash, verification_key_hash) triples. The schemes are leanSPHINCS signatures (0x10) and leanSTARK proofs (0x11).

Signatures and proofs live only in the mempool, which means they are not included in blocks. A user sends a transaction in a wrapper along with its proofs, and nodes merge the proofs recursively. The merged proof goes into every block in a new header field, recursive_stark. During execution a transaction assumes its dependencies are valid, and it pays gas for verification (3,000 for a signature, 30,000 for a STARK) and for its share of the header field (recursive_stark_gas, 30,000).

L2 block times are much shorter than the L1’s, so this structure cannot move over as it is: the proof would arrive after the block, so it cannot go in the header. Instead, in this PoC, an EIP-8288 aggregate proof covers many L2 blocks and is posted to the L1 separately as a new OP Stack batch type, 0x02. The rule that a block without its proof is invalid stays, but it applies later. During the derivation process, a block whose proof never arrives is replaced with a block that keeps only its deposits.

There are differences at the transaction level. EIP-8288 gives signatures and general programs different schemes, but this PoC does not split them and takes every program under a single scheme, 0x20.

An L2 has no public mempool for proofs to travel through, so the sequencer takes them over RPC, and op-aggregator, a component attached to the sequencer, merges them recursively.

The cost of posting the aggregate is charged the way an L2 charges for blobs: 2,048 bytes of L1 data fee for each dependency a transaction declares.

EIP-8288This implementation
Schemes 0x10, 0x11Scheme 0x20, a statement of any program
recursive_stark in the headerAggregate posted to the L1 as batch type 0x02
One proof per blockOne aggregate over many L2 blocks
Block invalid without its proofBlock without its proof replaced with its deposits
Mempool wrapper and recursive aggregationSequencer RPC and op-aggregator
recursive_stark_gas2,048 bytes in the L1 data fee

Transaction flow

image

A transaction declares the proofs it relies on as dependencies. A dependency is EIP-8288’s (scheme, data_hash, verification_key_hash) triple. This post calls the last two a statement: the hash of a program’s verifying key and the digest of the output that program published. For example, in a leanSPHINCS account, the dependency is the signature program’s statement that “this public key signed this transaction”.

The end-to-end flow goes like below:

  1. A user sends the transaction and its statement proofs to the sequencer, op-reth. Depending on the statement, a proof is either a raw leanSPHINCS signature or a leanVM proof the user made.
  2. op-reth verifies it before putting the transaction in the pool, so a user cannot push a transaction in with a bad proof.
  3. When a block is built, op-reth hands its dependencies to op-aggregator.
  4. op-aggregator bundles signatures, up to 100 at a time, into leaves with the signature program, takes users’ proofs as leaves directly, and merges each block’s leaves into one root of a leanVM tree. One aggregate covers an interval of consecutive blocks.
  5. Once the interval’s last block is merged in, op-aggregator attaches a final proof called the seal to the root and returns it to op-reth, and the batcher posts it to the L1.

The aggregate reaches the L1 after the transactions have run, so during execution declared dependencies are treated as proven. op-reth has already verified the proofs, so a block is only replaced when the sequencer or prover fails to produce an aggregate.

The L1 ends up with the aggregate proof and transactions that carry a public key and a program identifier instead of a large signature. After compression, a transaction would be 100 to 129 bytes, including its share of the aggregate’s statement list. The root and seal are about 354 KB regardless of how many statements they cover, or about 322 KB compressed.

leanVM and the proof tree

leanVM is a zkVM that proves a program’s execution. Running a program compiled to RISC-V produces a proof that it ran correctly, and that proof is built from hashes alone.

Proofs are merged through recursion. If you run a program that checks “these two proofs are valid” and then prove that run, two proofs become one, and repeating this gives a tree.

LayerWhat it does
LeafA proof of one program execution. The signature program makes one leaf that verifies 100 signatures
NodeVerifies the two proofs below it and merges them
RootThe top node, standing for every statement
image

EIP-8288’s recursive proof is meant to verify proofs of any program under a single fixed key, AGGREGATED_VK. leanVM’s aggregation tree can’t do that yet, since it only merges proofs of one program, and EIP-8288 itself has not settled this part: LEANSTARK_SCHEME (0x11) is still reserved. A privacy pool’s proofs can only join an aggregate once this is solved, so this PoC changes the tree in a fork of leanVM.

The first change separates the tree from the program. Based on an upstream proposal, a program is committed in its verifying key, and the tree’s circuits are fixed by size limits instead of by a particular program. This set of circuits is called a class. Its digest is a protocol constant and plays the part of AGGREGATED_VK. Every program within the limits goes through the same circuits, so a root costs the same to verify however many programs it holds. Small programs get slower when they share a circuit with much larger ones, so leaves come in three kinds: small ZK programs, signatures, and large ZK programs.

The second change lets one leaf prove many statements. A leaf publishes a list of values, and the tree’s first-layer circuit attaches the program’s verifying key digest to each value. Each such pair is one statement. Signatures are one case of this: the signature program verifies 100 signatures and publishes BLAKE2s("sphmem01" ‖ public key ‖ message) for each, so to the tree a signature looks no different from any other program’s output.

With this structure EIP-8288’s schemes cannot be used as they are. The data_hash of 0x11 is the public input hash of one proof and the verification_key_hash of 0x10 is a public key, whereas here data_hash is one value from the list and verification_key_hash is always a program’s verifying key digest. So signatures and programs both go under one new scheme, 0x20.

Verifying a root requires the verifier to compute values fixed in the circuits itself, which would take about 10 billion steps in Cannon. This PoC computes them ahead of time into a class key of about 1 KB and leaves the remaining check to a separate proof, the seal. A root and seal together verify in a few milliseconds and take about 800 million steps in Cannon. One root can hold up to 4,096 statements from up to 64 programs.

Accounts and authentication: EIP-8130 and EIP-8430

Instead of EIP-8141 frame transactions, this PoC uses the AA transaction of EIP-8130 (type 0x79, format in EIP-8430), which looks likely to reach L2s first. In EIP-8130 an account registers the parties that may approve transactions on its behalf in a Keystore contract. These parties are called actors, and a contract called an authenticator decides how an actor authenticates.

A typical authenticator verifies a signature carried in the transaction onchain. With that approach PQ signatures would go all the way to the L1, so this PoC adds a statement authenticator, which keeps the signature out of the transaction. An actor of this authenticator consists of a program’s verifying key, a key, and an 8-byte tag that identifies the format of what the program publishes. It means “this program proves that this key approved”.

Instead of a signature, the transaction carries the authenticator’s address and those three values in 92 bytes. From these and the transaction’s signing hash (the sender hash), a node computes the dependency (0x20, BLAKE2s(tag ‖ key ‖ sender hash), verifying-key hash), and once that dependency is proven, the key counts as having approved the transaction. The signature goes only to the sequencer, where it is used to build the proof, and never reaches the L1.

A leanSPHINCS account is one case of this structure: the program is the signature program, the tag is "sphmem01" and the key is a 32-byte public key. What the signature program publishes equals this dependency’s data_hash, so once the signature is in an aggregate, account authentication is done as well. A program that verifies Falcon or ML-DSA, or one that checks any policy, could serve as an account key the same way, but this PoC did not test that.

A transaction can also rely on other proofs, separately from account authentication. A transfer in a privacy pool is one example. Such dependencies are declared in the 0x04 entry of the transaction’s account_changes, up to 256 of them, and a contract reads them through functions this PoC added to the Transaction Context precompile and checks them in its own logic.

Fee mechanism

In this PoC the cost of the aggregate covers data only. Each dependency counts as 2,048 bytes and is added to the L1 data fee.

dependency_l1_cost = 2048 × deps × (baseFeeScalar × l1BaseFee × 16 + blobFeeScalar × l1BlobBaseFee) / 1e6

deps is the number of declared dependencies, duplicates included, plus STATEMENT authentications. The fee follows L1 prices and goes to the L1 fee vault. The 2,048 bytes a user pays for are less than the 4.9 KB of a signature carried in the transaction, so at the same L1 price, using an aggregate is cheaper.

L2 gas keeps only the verification cost: 3,000 for the signature program and 30,000 for any other, plus K1_AUTH_COST of 5,100 for a STATEMENT authentication, which makes 8,100 for leanSPHINCS.

The cost of running the prover is not part of this fee. With a single machine at full load, compute comes to about $0.0000027 per signature, which is small next to the data cost, but the machine runs regardless of load, so this can be substantial on the chains with low loads. We can have some solutions like a variant of the OP Stack’s operator fee, which was introduced to recover ZK proving costs, but this PoC does not cover that.

New batch submission rule: one aggregate every N blocks

OP Stack batcher closes a channel and submits it once the channel reaches a target size or max-channel-duration has passed. Neither condition has anything to do with proofs, so this PoC changes the submission rule to make channels close when aggregates are ready.

The new rule works off block numbers. For a configured value K, an interval ends at the block where (block number + 1) % K == 0. On a busy chain with K=12, that means one aggregate every 24 seconds in the chain whose block time is 2 seconds. A channel containing dependent blocks stays open until the boundary, even past max-channel-duration. It then closes and waits briefly for the aggregate to join it. If the aggregate misses that window, the channel is submitted without it and the aggregate goes into the next channel.

There are a few exceptions. A channel that hits its size limit before the boundary closes early, and the next channel runs to the same boundary. A channel nearing the end of the sequencing window is submitted immediately. Channels with no dependent blocks follow the existing rules.

The sequencer, prover and batcher each compute the boundary on their own, so they have no state to keep in sync. If the prover falls behind, aggregates arrive later but still cover the same number of blocks, so throughput is unaffected. Derivation only reads an aggregate’s through field, which means this is a batcher change and not a consensus change.

Choosing K is a tradeoff between cost and throughput. An aggregate takes about 322 KB no matter how much it holds, so posting one every 12 seconds costs 2.5 blobs per L1 block, while posting one every 24 seconds costs 1.2. In the other direction, an aggregate holds at most 4,096 statements, so a larger K lowers the number of statements each block can include, which can limit the throughput of transactions that use this feature.

New operational component: op-aggregator

Proving takes every core and several GB of memory, so this PoC moves it out of the sequencer into a separate process, op-aggregator. op-aggregator reads each block’s signatures and proofs from op-reth over an authenticated RPC, builds the aggregate, and hands it back.

The work has two parts with different constraints. Merging a block into the running root has to happen in order, one block at a time. Everything below that, proving a block’s leaves and joining them into a small tree, depends only on that block, so it can run in parallel and ahead of time. op-aggregator splits the work along this line. A main process does the merging, while separate helper processes prove upcoming blocks in advance. The main process then merges only the top of each block’s small tree into the root, so the in-order step happens once per block no matter how large the block is.

The results in section 3 use this setup on a single 32-vCPU machine, where throughput is limited mainly by the CPU time spent proving leaves and nodes. There is likely room to scale further, for example, helpers could run on other machines without being trusted, since the main process verifies the proofs they return, but the current implementation only starts them as child processes on the same machine. GPUs can be another option: about a third of the CPU time in a node proof goes to NTT and 28% to BLAKE2s, both candidates for acceleration, but leanVM has no GPU prover yet.

More machines would not change the aggregate’s limit of 4,096 statements, however. Going past that throughput means either lowering K to post aggregates more often, or improving the leanVM tree to hold more statements.

The aggregate and derivation

An aggregate is included in a batcher channel as a new batch type, 0x02, with the fields below. It proves the dependencies declared in blocks up to through.

FieldContents
versionFormat version
throughThe last L2 block this aggregate answers for
aggregation_vkey_hashDigest of the tree’s class key. Invalid unless it equals the protocol constant
statementsThe statements proven
proofRLP of the root and seal. Over 1 MiB it is invalid without being decoded

Derivation normally turns a batch into a block as soon as it reads it. That doesn’t work for a block with dependencies, because the validity of the block depends on an aggregate that appears later on the L1. So this PoC adds a derivation stage called the dependency gate. When a batch with dependencies arrives, the gate holds it while a scanner reads ahead through L1 data to find the aggregate that covers the block.

If block B’s batch was read at L1 block I, the scanner decodes the channels completed between I and I + 100, about 20 minutes of L1 blocks. It uses the pipeline’s frame and channel rules but runs separately from the pipeline. The first aggregate whose through reaches B is the one responsible for B. This choice depends only on I and L1 data, so a node following the chain and a fault proof starting from any block get the same answer.

Once that aggregate is found, its proof is verified and each of the block’s dependencies is checked against its statement list. If all of them are there, the batch is used unchanged. If any is missing, the block is invalid and gets replaced by a block containing only its deposits, the same way OP interop handles a block with an invalid message.

Verifying aggregates in fault proofs

The PoC’s OP Stack chain is built as an optimistic rollup, so aggregate verification also has to be reproducible inside a fault proof. In a dispute, kona re-runs derivation on Cannon (MIPS64). The dependency gate and scanner run as usual, finding the responsible aggregate in L1 data and verifying the leanVM proof inside Cannon.

The largest cost of verifying a leanVM proof in Cannon comes from the class key. If the key holds the tree’s fixed values directly, it reaches 60 MB, and verifying one aggregate of 4,096 statements takes about 4.2 billion steps and 275 MiB. With the class key of about 1 KB and the seal described earlier, this PoC brings verifying one aggregate down to about 800 million steps and 13–14 MiB. A full disputed state transition takes about 2 billion steps and about 70 MiB, far below Cannon’s trace limit of about 2^{42} steps.

Deciding a block can take L1 data up to I + 100, so a dispute game’s L1 head has to reach that block too. The gate therefore records the L1 block its decision used, and the block counts as derived from that L1 block. The challenger uses this value to judge whether a claim can be proven with the game’s L1 head.

Three end-to-end tests cover this path: the kona fault proof program proving the state transition of a block answered for by a later aggregate, the same program proving a block that was replaced because no aggregate arrived, and a real dispute game in which an attacker bisects down to a single instruction inside aggregate verification and the honest challenger runs it in Cannon and counters with an on-chain step.

3. Results

Throughput and blobs

The measurements ran on one AWS c7a.8xlarge (32 vCPUs), with leanSPHINCS signatures on a 2-second chain and one aggregate every 12 blocks, or 24 seconds. Blobs are an average per L1 block, calculated from the aggregate size and how well the transactions compress.

image
Signatures per blockSignatures per secondSignatures per aggregateBlobsBlobs with signatures inline
85431,0201.6–1.720
170852,0402.0–2.239
2551273,0602.4–2.859
3401704,0802.8–3.379

The prover kept up with the chain at every load, and 340 per block, the limit for a 12-block interval, gave the same result in two measurements. At higher loads one machine tops out at about 180 to 195 per second. Compute costs about $2.7 per million signatures.

With signatures inline, a PQ transition does not fit in the L1’s capacity. A blob holds 26 PQ transactions, so 170 per second needs 79 blobs per L1 block, nearly four times today’s L1 blob limit of 21. Even a single chain using every blob on the L1 would reach only about 45 per second.

With aggregates, the same load fits in 2.8 to 3.3 blobs. That is 96% less than with signatures inline, and 1.5 to 1.8 times what the same transactions would take with ECDSA. The cost of moving to PQ signatures drops from 43 times to under 2 times. Measured by 6-blob channels, 155 transactions fit with signatures inline and about 3,550 to 4,096 fit with this structure.

The shape of the cost changes as well. With signatures inline, blobs grow in direct proportion to signatures. Here the proof’s share stays at about 1.2 blobs per L1 block however many signatures there are, and the rest is data from transactions and the statement list. So the structure pays off more as load rises. At 43 per second it takes 3.5 to 3.8 times ECDSA, at 170 per second 1.5 to 1.8 times, and it beats inline signatures from about 3 per second with a 24-second interval. ZK program proofs go into the same aggregate as signatures, so more applications do not add to this fixed share.

4. Real application costs

What would it cost to run a real application that relies on PQ proofs on this structure? To find out, I ported Privacy Boost (sunnyside-io/privacy-boost-protocol) and a leanSPHINCS account and ran them on a devnet.

Privacy Boost is a pool that hides the contents of users’ transfers and shows with ZK proofs that they are valid. It currently verifies Groth16 proofs in a contract. Groth16 is pairing-based and not PQ, so quantum resistance means changing the proof system, but posting leanVM proofs directly to the L1 is not workable. One proof is about 430 KiB, or 3.4 blobs, so with one proof per transfer, this one pool using all 21 blobs of the L1 would top out at 6 transfers per L1 block, or 0.5 per second.

So the Groth16 verifiers are replaced with a contract that checks EIP-8288 statements. The contract no longer takes proofs and only checks that the transaction relies on the right statement. The Privacy Boost server makes the proof and sends it to the sequencer, and it goes into an aggregate. As a result, the bytes a single transfer pays for in the L1 fee fall from about 445 KB to about 5.1 KB, roughly an 86-fold reduction.

The leanSPHINCS account works out similarly. With the signature carried in the transaction, about 5 KB of signature has to go to the L1, and on top of that a contract has to perform 497 hashes, Merkle paths included. It would be much faster than using raw signature, since verifying SPHINCS- signature (keccak variation) would costs about over 100k gas per transfer. In this structure the user only makes a signature and op-aggregator does the proving, so the bytes in the L1 fee drop to about 2.2 KB and L2 gas to 35,250, about 6.6 times less.

TransactionL2 gasL2 gas if posted directlyL1 fee bytesL1 bytes if posted directly
Transfer from a leanSPHINCS account35,250~100,000~2.2 KB~5 KB
One transfer in the privacy pool~330,000Not measurable~5.1 KB~445 KB

5. Future work

This PoC moves only transaction authentication into proofs. The chain is still an optimistic rollup, state transitions are re-executed on Cannon only in a dispute, and aggregates ride in batches and take up blob space. This structure follows from two limits, that leanVM cannot yet prove long executions and that the L1 does not have EIP-8288, and it can change once they lift.

With continuations in leanVM, state transitions could be proven too. Today one execution is one proof and proof size is capped, so a long execution cannot be split across linked proofs. If a program proving block execution also verified the aggregate recursively, the chain would become a full ZK rollup, with no need for Cannon fault proofs or a separately posted aggregate, since proofs would no longer have to follow each channel and could be posted far less often. Without the roughly 1.2 blobs per L1 block that aggregates take, the blobs needed at 170 per second would fall from 2.8–3.3 to 1.6–2.0, and a 6-blob channel would hold about 6,000 to 7,800 transactions, close to ECDSA’s roughly 6,600.

With EIP-8288 on the L1, aggregates could leave blobs even before a move to a ZK rollup. Once the reserved leanSTARK scheme 0x11 is enabled, an L2’s proof could become a dependency of an L1 transaction, travel through the mempool, and be merged into the L1 block’s one recursive_stark.

The tree itself also has room to improve. On the same machine, proving signature leaves alone reaches 421 per second, but with the merging step included, throughput stops at about 180 to 195 per second. Every node in today’s tree uses the same circuit, so even the lowest nodes are proven at a size meant for 4,096 statements, which is also why raising the limit to 16,384 dropped throughput by 36%. Proving the lower layers with small circuits sized to what they actually hold, and using large circuits only near the top, looks like it could raise throughput and capacity together.


Visit Topic to respond.

You are receiving this because you enabled mailing list mode.