Account Abstraction Part 4: Aggregate Signatures

David Philipson
February 14, 2023
Technical

Aggregate signatures

Our current implementation validates each user op in the bundle separately. This is a very straightforward way to think of validation, but potentially wasteful. Checking signatures can end up on the expensive side gas-wise because doing so requires quite a bit of cryptographic arithmetic.

Wouldn’t it be nice we could validate many ops at the same time with just one signature instead of many?

To do so depends on a concept from cryptography, aggregate signatures.

A signature scheme that supports aggregation provides a way, given multiple messages signed with different keys, to generate a single combined signature such that verifying the combined signature implies that all the constituent signatures are also valid.

A common example of a signature scheme that supports aggregation is BLS.

This optimization is particularly useful for implementing rollups, since the main goal of a rollup is data compression, and signature aggregation lets us compress the signature portion.

For more about space savings from signature aggregation, see Vitalik’s tweet on the subject.

Introducing aggregators

Right away, we see that not all user ops in a bundle can have their signatures aggregated together. Remember that a wallet is permitted to use whatever arbitrary logic it wants to validate the signature it’s given, and so there may be various signature schemes present in the same bundle.

Since we likely can’t aggregate signatures from different schemes, our bundle will end up with groups of ops, each group using a distinct aggregation scheme or no aggregation scheme at all.

Since we need to have various aggregation schemes represented on chain each with its own logic, we’ll have each aggregation scheme represented by a contract that we’ll call an aggregator.

An aggregation scheme is defined by how it combines multiple signatures into one and by how it validates the combined signature, so an aggregator exposes these two functions as methods:

contract Aggregator {
  function aggregateSignatures(UserOperation[] ops)
    returns (bytes aggregatedSignature);

  function validateSignatures(UserOperation[] ops, bytes signature);
}
An aggregator contract combines ops from multiple users into a group with a single signature.

Since each wallet is defining its own signature scheme, it’s up to each wallet to decide which aggregator it’s compatible with, if any.

If a wallet wants to participate in aggregation, it exposes a method to choose its aggregator:

contract Wallet {
  // ...

  function getAggregator() returns (address);
}

Using this new getAggregator method, the bundler can group together ops that have the same aggregator and use that aggregator’s aggregateSignatures method to compute a combined signature for them.

A group might look like this:

struct UserOpsPerAggregator {
  UserOperation[] ops;
  address aggregator;
  bytes combinedSignature;
}
If a bundler has off-chain knowledge about a particular aggregator, it can optimize by hardcoding a native version of the signature aggregation algorithm instead of running aggregateSignatures as EVM code.

Next, we need to update the entry point contract to make use of the new aggregators.

Recall that the entry point has a handleOps method that takes a list of ops.

We’ll give it a new method, handleAggregatedOps, which does the same thing but takes in the ops grouped by aggregator:

contract EntryPoint {
  function handleOps(UserOperation[] ops);

	function handleAggregatedOps(UserOpsPerAggregator[] ops);

  // ...
}

The new method, handleAggregatedOps, works largely the same as handleOps. The only difference is in its validation step.

While handleOps performs validation by calling each wallet’s validateOp method, handleAggregatedOps will instead call the aggregator’s validateSignatures method on each group’s combined signature using that group’s aggregator.

The executor uses the aggregator to group ops together before sending them to the entry point, so they can all be validated at the same time.

We’re almost done!

But there’s one issue here that’s pretty familiar by now.

The bundler wants to simulate validation and check that the aggregator will validate a group of ops before it includes those ops in the bundle, because if validation fails the bundler is forced to pay for gas. But an aggregator with arbitrary logic can easily succeed during simulation but fail during execution.

We’ll solve this in exactly the same way we did for paymasters and factories: we restrict what storage the aggregator can access and what opcodes it can use, and require that it stake ETH in the entry point unless it doesn’t access storage.

And that’s that for aggregated signatures!

Wrap up

What we’ve created here is more or less the full architecture of ERC-4337! There are some differences in the details, such as the names and arguments of some of the methods, but there is nothing left that I would consider an architectural difference. If I’ve done my job well, you should now be able to read the real ERC-4337 and understand what’s going on.

If you’ve made it this far, thanks so much for reading my explanation! I hope it helped you as much as it helped me to write it.

Addendum: Differences from ERC-4337

While we’ve got the overall architecture of account abstraction down, the smart people behind ERC-4337 thought of some things that are slightly different from what we described above.

Let’s go over some of them!

1. Validation time ranges

Above, I was pretty vague about the return type of the wallet’s validateOp and the paymaster’s validatePaymasterOp. ERC-4337 finds a good way to make use of this.

Something that a wallet would very much like to do is only allow a user op to be valid for a certain amount of time. Otherwise, a rogue bundler could sit on that operation for a very long time, and then include it in a bundle much later at a time advantageous to the bundler.

The wallet might want to defend against this by checking the TIMESTAMP during validation to make sure it’s not too far in the future, but it can’t, because we’ve banned TIMESTAMP during validation to stop simulations from being inaccurate. This means the wallet needs another way to indicate at what times the operation is valid.

Thus, ERC-4337 gives validateOp a return value that the wallet can use to choose a time range:

contract Wallet {
  function validateOp(UserOperation op, uint256 requiredPayment)
    returns (uint256 sigTimeRange);
  // ...
}

This return value represents the time range at which the operation is valid as two 8-byte integers one after the other.

One other note from ERC-4337: wallets should return a sentinel value from validateOp rather than reverting in the case of validation failure, which helps out with gas estimation because eth_estimateGas doesn’t tell you how much gas was used in a transaction that reverts.

2. Arbitrary call data for wallets and factories

We said that the interface of our wallet was:

contract Wallet {
  function validateOp(UserOperation op, uint256 requiredPayment);
  function executeOp(UserOperation op);
}

In ERC-4337, wallet’s don’t actually have a method named executeOp.

Instead, the user operation has a callData field:

struct UserOperation {
  // ...
  bytes callData;
}

This is passed to the wallet as call data.

For a typical smart contract, the first four bytes of this data will be interpreted as a function selector and the rest as function arguments.

This means that other than the required validateOp method, wallets can define their own interface, and user operations can be used to call arbitrary methods on the wallet.

Along the same lines, in ERC-4337 the factory contracts don’t actually have a deployContract method. They too receive arbitrary call data, in this case from the op’s initCode field.

3. Compact data for paymasters and factories

Above we said that the user operation contained fields to specify a paymaster as well as what data to pass to it:

struct UserOperation {
  // ...
  address paymaster;
  bytes paymasterData;
}

In ERC-4337, these are combined into one field as an optimization, where the first 20 bytes of the field are the paymaster address and the rest is the data:

struct UserOperation {
  // ...
  bytes paymasterAndData;
}

The same is true for factories and the data sent to them: while we used two fields factory and factoryData, ERC-4337 combines these into a single field initCode.

Ok, you did it!

We hope you learned a lot about Account Abstraction.

You Could Have Invented Account Abstraction

Missed the beginning of this 4-part series? Go back and read from the beginning!

  1. Account Abstraction Part 1: Protect Our Assets
  2. Account Abstraction Part 2: Sponsoring Transactions with Paymasters
  3. Account Abstraction Part 3: Wallet Creation

More articles