Note: This doc is pre-Dencun. Blobs have changed the economics dramatically, but compression will still matter for future scalability. Here's another doc all about that.
Based on the implemented and upcoming methods in the WAX project by PSE, supported by the Ethereum Foundation.
We're here to support the ecosystem - everything is freely available and modular. You can use exactly these methods, and you can also remix the parts you like together with your own ideas. Source code is in our repository and we're available to help via discord.
(WAX is also about the wonderful features enabled by 4337, but this doc focuses on fee optimization.)
Simple user ops can be compressed to about 18 bytes.
Using these methods, bundlers can potentially charge the right-most column in the table below while turning a profit:
ETH Transfer | EOA | 4337 | 4337 Compressed |
---|---|---|---|
Mainnet | $ 1.4196 | $ 6.5018 | $12.1660 |
Arbitrum One | $ 0.1359 | $ 0.4868 | $ 0.0868 |
Optimism | $ 0.0935 | $ 0.3131 | $ 0.0302 |
ERC20 Transfer | EOA | 4337 | 4337 Compressed |
Mainnet | $ 2.7040 | $ 7.1406 | $13.1597 |
Arbitrum One | $ 0.2054 | $ 0.5316 | $ 0.0918 |
Optimism | $ 0.1155 | $ 0.3412 | $ 0.0312 |
Google sheet.
Interactive calculator.
When using 4337, the full calldata to run a transaction to process a bundle still needs to start with a regular ECDSA-signed ethereum transaction. Using 1559, the format is:
0x02 || rlp([chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, destination, amount, data, access_list, signature_y_parity, signature_r, signature_s])
https://eips.ethereum.org/EIPS/eip-1559
Without changes to the protocol (which we'll consider out-of-scope here), we can only compress the data
field above. This leaves us with about 110 other bytes which ultimately need to be supported by the contained user operations. More user operations = less shared cost per user operation.
EntryPoint
vs Account CompressionCompression can take place in two independent phases:
EntryPoint
: Applied to the EntryPoint
's calldata (ie bundle and beneficiary), compressed by the bundler and decompressed by a contract which wraps the EntryPoint
userOp.callData
, compressed by the wallet (the user's off-chain software) and decompressed by the user's accountEntryPoint
CompressionThis compression introduces a wrapper contract (eg) which calls the EntryPoint
. The wrapper takes the compressed calldata, decompresses it, and calls the EntryPoint
.
Relatively simple universal encodings can be used today to benefit bundlers without the need to get wallets involved or delve into the complexities of the calldata that gets passed to each wallet.
For example, sending a single user op requires sending bytes like this to EntryPoint
:
1fad948c
- handleOps(UserOperation[],address)
0000000000000000000000000000000000000000000000000000000000000040
- ops array is located at 0x40 (= 2x 32 bytes)
000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266
- beneficiary address
0000000000000000000000000000000000000000000000000000000000000001
- array length (ie there's 1 user op)
Array data:
0000000000000000000000000000000000000000000000000000000000000020
- the user op is located 0x20 bytes after the array data
Start of the user op:
000000000000000000000000b734eb54c90c363d017b27641cc534caf7004fc4
- sender address (aka the user's account address)
0000000000000000000000000000000000000000000000000000000000000001
- nonce = 1
0000000000000000000000000000000000000000000000000000000000000160
- initCode is located 0x160 bytes after the start of the
user op
0000000000000000000000000000000000000000000000000000000000000180
- callData is located 0x180 bytes after the start of the
user op
000000000000000000000000000000000000000000000000000000000001228f
- callDataGasLimit is 74,383 (=0x01228f)
00000000000000000000000000000000000000000000000000000000000186a0
- verificationGasLimit is 100,000 (=0x186a0)
000000000000000000000000000000000000000000000000000000000000d494
- preVerificationGas is 54,420 (=0xd494)
000000000000000000000000000000000000000000000000000000003e08feb0
- maxFeePerGas is 1,040,776,880 (=0x3e08feb0, about 1.04
gwei)
000000000000000000000000000000000000000000000000000000003b9aca00
- maxPriorityFeePerGas is 1 gwei (=0x3b9aca00)
0000000000000000000000000000000000000000000000000000000000000220
- paymasterAndData is located 0x220 bytes after the start of
the user op
0000000000000000000000000000000000000000000000000000000000000240
- signature is located 0x240 bytes after the start of the
user op
0000000000000000000000000000000000000000000000000000000000000000
- length of initCode is zero
0000000000000000000000000000000000000000000000000000000000000064
- length of callData is 0x64 (= 3x 32 + 4 bytes)
2d1634c5
0000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000008
0103000001810000000000000000000000000000000000000000000000000000
- callData
00000000000000000000000000000000000000000000000000000000
- padding (since callData was not a multiple of 32)
0000000000000000000000000000000000000000000000000000000000000000
- length of paymasterAndData is zero
0000000000000000000000000000000000000000000000000000000000000041
- length of signature is 65 (=0x41)
6d0d86052da1995cb95f7d51fe68375c182e82822263a38a4976251e6d4a6918
162b605c71bea200f55e314cdea53112ab61a440fa1a0d260e119ebe417780be
1c
- signature
00000000000000000000000000000000000000000000000000000000000000
- padding (since signature was not a multiple of 32)
These bytes are required because it is the layout determined by the Solidity ABI. Instead of sending them in the top-level transaction, we can have another contract send those bytes instead. It can decode the bytes below and pass along the equivalent Solidity ABI encoding to the contract. By generating the extra data inside the transaction, it doesn't need to posted to L1, so we avoid paying for it.
01
- 1 user op (VLQ)
04
- bit stack encoding (1)00 (0x04 == 0b100)
- Reading least signficant bit first:
- 0: initCode is empty
- 0: paymasterAndData is empty
- 1: end of stack
c90c36
- Sender (using a lookup table containing:
c90c36 => b734eb54c90c363d017b27641cc534caf7004fc4)
01
- nonce = 1
(no bytes)
- initCode is empty, but we don't need any bytes for it
because the bit stack already indicated it's empty
64
- length of callData is 100 (VLQ)
2d1634c5
0000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000008
0103000001810000000000000000000000000000000000000000000000000000
- callData
185d
- callDataGasLimit is 74,400 (PseudoFloat)
3100
- verificationGasLimit is 100,000 (PseudoFloat)
1944
- preVerificationGas is 54,500 (PseudoFloat)
410d
- maxFeePerGas is 1.05 gwei (PseudoFloat)
3900
- maxPriorityFeePerGas is 1 gwei (PseudoFloat)
(no bytes)
- paymasterAndData is empty, but we don't need any bytes for
it because the bit stack already indicated it's empty
41
- length of signature is 65 bytes (VLQ)
6d0d86052da1995cb95f7d51fe68375c182e82822263a38a4976251e6d4a6918
162b605c71bea200f55e314cdea53112ab61a440fa1a0d260e119ebe417780be
1c
- signature
This reduces the effective bytes for the bundle from 319 down to 113.
Of those 113:
userOp.callData
and userOp.signature
, which will be reduced elsewhere (see Account Compression and BLS Signature Aggregation)userOp
fieldsDetails about the encoding above:
uint256
value, which is then interpreted using this solidity libraryc90c36
) isn't enough for all addresses, but using RegIndex we can give 3-byte IDs to the first 8 million addresses, 4 bytes for the next billion addresses, and so on (for context, 250m addresses have been used on L1)userOpHash
and therefore changes the required signature. Without rounding, 5-10 additional bytes will be used.EntryPoint
compression can also go further by compressing the calldata being passed to wallets, potentially eliminating the need for account compression. This could be challenging if trying to support a variety of evolving calldata formats for different competing smart accounts, but could be convenient if the bundler and smart account are provided by the same organization.
This compression applies to the userOp.callData
field, which determines the bytes the EntryPoint
sends to the account. Decompression occurs inside the account to determine the action(s) to perform.
This allows accounts to benefit from compression without relying on a dedicated bundler, and to freely switch bundlers without losing their compression features.
Using compression at this level reduces the implementation complexity of bundlers, but account compression cannot help with the many other fields of each user op, so some amount of EntryPoint
compression is always recommended.
For example, an account without compression probably uses a method like this:
function execute(
address dest,
uint256 value,
bytes calldata func
) external {
// ...
}
(This method is from eth-infinitism's SimpleAccount
example.)
To send an ERC20 token this way, our userOp.calldata
field will be encoded like so:
b61d27f6
- execute(address,uint256,bytes)
000000000000000000000000c845d6b81d6d1f3b45f2353fec8c960085a9a42e
- dest / ERC20 address
0000000000000000000000000000000000000000000000000000000000000000
- value (zero because we're not sending ETH)
0000000000000000000000000000000000000000000000000000000000000060
- location of func (0x60 = 3x 32 bytes)
0000000000000000000000000000000000000000000000000000000000000024
- length of func (0x24 = 2x 32 + 4 bytes)
a9059cbb
- 4 bytes for transfer(address,uint256)
000000000000000000000000e30a735c9b90549f8171f17dd698ab6048dde5ab
- recipient address
0000000000000000000000000000000000000000000000000de0b6b3a7640000
- one token (10 ^ 18)
00000000000000000000000000000000000000000000000000000000
- padding so that if there was a next field of
execute(address,uint256,bytes), it could be inserted next
and be on a 32-byte alignment boundary
This is the encoding we get when we use the solidity ABI. It was designed to be efficient to compute for the 256-bit EVM, and it is, but this makes it very inefficient for bytes.
In total, this uses 228 bytes. Zeros are cheaper though (about 75% cheaper depending on the L2), so it's more useful to think of it as 98 effective bytes (ie the equivalent number of non-zero bytes with the same cost).
An account with compression can achieve the same thing by receiving this calldata:
02
- Compression scheme to use (02 = ERC20 transfer)
6a
- Token (using a lookup table containing:
6a => c845d6b81d6d1f3b45f2353fec8c960085a9a42e)
473dee
- Recipient (using a lookup table containing:
473dee => e30a735c9b90549f8171f17dd698ab6048dde5ab)
9900
- Amount = 10^18, encoded as a PseudoFloat
This reduces the effective bytes for userOp.calldata
from 98 to 6.
Details about the encoding above:
6a
) is not enough for all ERC20 tokens, using VLQ we can use single-byte IDs for the most popular tokens, two-byte IDs for the 16,384 next most popular tokens, etc473dee
) isn't enough for all addresses, but using RegIndex we can give 3-byte IDs to the first 8 million addresses, 4 bytes for the next billion addresses, and so on (for context, 250m addresses have been used on L1)An ECDSA signature uses 65 bytes. After switching to compact encodings for other fields, this usually becomes the vast majority of the bytes needed for the user op.
BLS signatures are marginally smaller at 64 bytes, and can be used to verify an unlimited number of transactions from different parties. Effectively, the cost of a single BLS signature can be shared between all user ops.
BLS signatures also come with higher L2 gas costs:
ECDSA | BLS | |
---|---|---|
Intrinsic to bundle | 0 | 90,000 |
Added by user op | 3,000 | 36,000¹ |
Note: This is all based on BLS on the BN254 curve. BLS can also be done on the BLS12-381 curve (used in the beacon chain), but this needs to wait for new precompiles to make it viable in the EVM.
Param | Value | About |
---|---|---|
ETH | $2,600 | Latest (at time of writing) |
ETH gas | 26 gwei | Median of last month |
Arbitrum gas | 0.1 gwei | Median of last month |
Optimism gas | 0.0054 gwei | Median of last month |
The exact details of how L2 chains charge fees vary between chains. For example, Arbitrum increases its gas values to account for the L1 gas it needs to pay, but Optimism charges for L1 gas separately (in addition to gasPrice
* gasUsed
).
For our purposes, L2 fees can be predicted with good accuracy by finding the right parameters for the following unified model:
l1GasUsed
= fixedL1Gas
+ l1GasPerEffectiveDataByte
* dataBytes
l2GasUsed
= (ordinary gas defined by protocol / same on L1 and local dev)
fee
= l1GasUsed
* l1GasPrice
+ l2GasUsed
* l2GasPrice
fixedL1Gas
is a minimum amount of L1 gas charged for all transactions.
l1GasPerEffectiveDataByte
is an amount of L1 gas charged per effective byte in the data
field (ie the bytes sent to the destination address). Here 'effective' is slightly under-defined, but incompressible data, which is a good approximation of our use case (because we've already compressed it), should be 100% 'effective'. In other words, every byte we're putting in the data field counts for one 'effective' byte.
While it's possible to find these parameters theoretically by diving into the details of each L2, it's easier and less error prone to measure them directly with real transactions. This can be done with the help of the WAX Fee Measurer.
At the time of writing, this methodology yields the following:
Parameter | Arbitrum One | Optimism |
---|---|---|
fixedL1Gas |
1816 | 1302 |
l1GasPerEffectiveDataByte |
16.2 | 11.0 |
Note:
data
field)l2Gas
is particularly prone to interpretation error, because Arbitrum reports "gasUsed" as whatever number satisfies fee = gasUsed * gasPrice
. Not that that is a bad idea, but it is not what we mean in this model. Other L2s might have similar complications in their reported gas numbers. It is essential to measure gas independently in a dev environment when applying this model.In EntryPoint Compression, we saw 113 bytes to encode a bundle containing one ETH transfer. By replacing the 33 effective bytes in the userOp.callData
field with the 6 effective bytes from the Account Compression example, this brings our bundle size down to 86 (and performs an ERC20 transfer instead).
Based on experiments with WAX prototypes, the ordinary gas (L2 gas) estimate for this bundle is 174,000.
It's important now to distinguish between costs that are intrinsic to the bundle and costs that are added by each user op. To be cost effective, a bundle should contain several user ops, and the more the merrier.
Effective bytes | L2 Gas | |
---|---|---|
Intrinsic to bundle | 2 | 34,000 |
Added by user op | 84 | 140,000 |
We can now adjust the above for BLS signatures. 66 of those 84 user op bytes are attributable to the ECDSA signature, and we can replace all of them with a single 128-byte BLS signature. For gas, we can save 3,000 per user op by not doing ECDSA, but we need to pay 36,000 per user op¹ and 90,000 fixed gas to verify the BLS signature.
Adjusting with these values, we get:
Effective bytes | L2 Gas | |
---|---|---|
Intrinsic to bundle | 66 | 124,000 |
Added by user op | 18 | 173,000 |
We can now combine these numbers with the fixedL1Gas
and l1GasPerEffectiveDataByte
to add the L1 gas values for bundles and user ops for both Arbitrum and Optimism. fixedL1Gas
is intrinsic to the bundle because we only pay it once per bundle.
Arbitrum One | L1 Gas² | L2 Gas² |
---|---|---|
Intrinsic to bundle | 2,885 | 124,000 |
Added by user op | 292 | 173,000 |
Optimism | L1 Gas | L2 Gas |
---|---|---|
Intrinsic to bundle | 2,028 | 124,000 |
Added by user op | 198 | 173,000 |
At this point, let's pick 10 for the number of user ops in our hypothetical bundle. This is a balance between demonstrating low-cost potential and a size that isn't too far out of reach. This will result in L1 costs that are about 2x the theoretical minimum (infinite bundle size). If you could get to 30 ops/bundle, that would go down to about 1.3x.
Bundle size is a tradeoff between fees and latency. The more you want a lower fee, the more you're willing to wait to share it with more users. There is a larger game-theoretic story to tell here, since users willing to pay higher fees are not negatively affected by users who want to pay the smallest possible share of the bundle overhead. This could lead to users either paying the whole bundle overhead or none of it, with users willing to pay the full overhead having no incentive then to use a third party bundler. It'll be interesting to see how this plays out.
Anyway, to get the total fees per user op, the bundler should also include a profit margin as its incentive to operate. 5% has been included below.
Fees for ERC20 transfer | L1 Gas² | L2 Gas² |
---|---|---|
Arbitrum One | 609 | 194,670 |
Optimism | 421 | 194,670 |
We can combine these with actual gas prices and the value of ETH from Current Fee Environment to get total fees in USD:
Fees for ERC20 transfer | L1-based | L2-based | Total |
---|---|---|---|
Arbitrum One | $0.0412 | $0.0506 | $0.0918 |
Optimism | $0.0284 | $0.0027 | $0.0312 |
Finally, blobs are coming. 4844 should significantly reduce the L1-based fees above. Exactly how much is very difficult to predict, but 20x is a low discount compared to numbers I've heard and greater discounts wouldn't affect the totals above too much. So, assuming 20x the new fees are:
Fees for ERC20 transfer (4844) | L1-based | L2-based | Total |
---|---|---|---|
Arbitrum One | $0.0021 | $0.0506 | $0.0527 |
Optimism | $0.0014 | $0.0027 | $0.0042 |
In this scenario where fees become dominated by L2 itself and not the costs of posting to L1, it will be important to re-evaluate our compression choices. Today, compression is easily profitable because all the compression work happens on L2 and the L2 costs are super low. When that flips, compression will probably need to change.
¹ It might seem strange that BLS has a per-user-op gas cost. This is because BLS verification involves processing not just the signature data but also the associated message data, and each user op adds message data.
² Care needs to be taken when interpreting gas on Arbitrum. See Demystifying Arbitrum’s Fees.