ERC-4337: Account Abstraction via Entry Point Contract specification

Happy to hear there’s work being done on this! Does your BLS wallet aggregate transactions across operations? That’s a feature that, while not especially useful for regular wallets because calldata costs are not that high relative to ECPAIRING costs, is a lifesaver for optimistic rollups. Would be interesting to see how it could be incorporated into the same standard.

1 Like

Happy to hear there’s work being done on this!

Thanks EF :slight_smile:

across operations

I have a card in the github project: “Consider design of single signature for multiple txs (eg exchanges frequent need of approve then transfer)”, if that is what you mean?

Currently an aggregator (server) would put together bls-signed tx data for an arbitrary set of txs (from any blswallet) that fit in an L2 tx, they could be txs from the same address with consecutive nonces, each individually signed-for though (before aggregation).

The bundler should be able to take all the BLS signatures in the user operations, and combine them together into a single BLS aggregate signature that goes on chain. When verifying the ops, the contract would extract the entire list of (pubkey, msghash) pairs that wallet verification execution expects, and check the aggregate signature against that whole list.

Is that what you have in mind?

1 Like

Yes, that’s what it does → bls-wallet-contracts/VerificationGateway.sol at main · jzaki/bls-wallet-contracts · GitHub
EDIT: bundling here

This sounds very similar to https://docs.opengsn.org/ Did you look into this and where there are parallels and differences? Also something like this has been attempted in the past with EIP-1077: Gas relay for contract calls. I would love to see what is different from then to now.

I also think that pushing EIP-2937: SET_INDESTRUCTIBLE opcode with that would be super helpful as it would enable more wallet to comply to this. For example the current Gnosis Safe setup would not be usable with the current proposal as it supports delegatecalls.

I would also be interested in what relation the Bundlers stand to the Miners/Validators. It sounds like these would be a complete different service. Or would you expect this to be part of a default Miner/Validator setup.

3 Likes

I would also be interested in what relation the Bundlers stand to the Miners/Validators. It sounds like these would be a complete different service. Or would you expect this to be part of a default Miner/Validator setup.

Bundlers would either be miners/validators, or they would be actors publishing bundle txs through Flashbots.

2 Likes

Excuse me if I misunderstood something, but I could not find how the preVerificationGas field is handled. Or is it the responsibility of the account abstraction account to handle and use these fields correctly?

Especially what’s interesting is at what step the preVerificationGas is paid.

preVerificationGas covers all the gas that can’t be checked on-chain using gasleft() deltas, but is known to be paid by both the calling user and the miner.
It covers static gas cost (e.g 21000 stipend, and little more used by handleOps other), and dynamic cost which depends on actual UserOp structure (e.g calldata cost, and memory usage/copy into the inner methods of handleOps.

But how is it charged from the user?

Do I get it right that it is the responsibility of the user account to pay for it? The same as for the actual fee? In the specification, preVerificationGas is a part of UserOperation.

That means that there is no way to enforce that the miner (or the one who submits this bundle of UserOperations) will get paid.

1 Like

To summarize the complete transaction payment:

  • The EntryPoint calculate the total cost of the UserOp, and charges the wallet (or paymaster) for that and transfer that amount to the bundler/miner.
  • the payment is split into 2 parts: those we can calculate on-chain (using gasleft() wrappers), and those we can’t.
  • the paymaster calculates the cost of verification, target call gas (and postOp, in case a paymaster is used to pay instead of the wallet itself.)
  • to this value it adds the preVerificationGas, which should be set to the excess (calldata cost, and some static cost we can’t calculate on-chain)
  • The preVerificationGas is calculated by the user who creates and signs the request.
  • The bundler/miner - verifies this value before putting it on-chain, to make sure it makes profit on this transaction.

Notes:

  • The UserOp contains 2 other gas values, the verificationGas and callGas. The user/paymaster must have balance to pay these values, but eventually pay only for the actual used gas. The preVerificationGas is paid in full.
  • preVerificationGas name is misleading a little: most of its value indeed comes before the verification, but it covers also some static overhead that comes later.
1 Like

The ERC looks pretty good, yet I was wondering about adding a chainId field into the UserOperation.

Since transactions have it (EIP-155) to prevent replay attacks, I believe the same attack vector comes in play in regards the UserOperations. Or am I wrong?

1 Like

Thanks, you are right, and the implementation actually covers chainId as part of the signature. The UserOperation struct doesn’t include a chain ID field, but it is appended before signing/verifying. The EIP should reflect that as well.

@yoavw Thanks for linking it to the contract. I am not sure how this is going to prevent replay attacks. The chainId is taken from the block and hashed with the UserOperation. As an attacker, I still could take the UserOperation and use it on a different chain, or not?

Is there something I am overlooking?

The user signed userOp.hash(), address(this), block.chainid and taht’s what the wallet will verify. This signature is a part of the the UserOperation. If someone relays the same UserOperation on another chain with a different chainid, the wallet will revert during its validateUserOp because the signature won’t match the requestId it receives from EntryPoint.

Do you see a way to bypass that, and successfully replay the UserOperation on a chain with a different chainId?

Alright, I get the flow know and was able to follow the steps in the code :slight_smile: Thanks for the explanation @yoavw

Note that balance cannot be read in any case because of the forbidden opcode restriction. Writing balance (via value-bearing calls) to any address is not restricted.

Doesn’t allowing value-bearing called essentially allow reading the balance of an account?

For example, if the wallet includes this in validateUserOp

bool success = this.send(500);

success will contain 1 iff the contract contains at least 500 wei. If we want the exact balance, we can just binary search.

Also, what happens if the wallet has all funds removed between simulation time and run time? Is the idea that this shouldn’t be possible because the handleOps call will always be the first transaction in a block?.

Yes, the wallet could check its own balance this way, much like it could check its own storage. The user would be able to invalidate an op that is already in the mempool by using a non-op transaction to change the wallet’s balance.

However, the wallet can only check its own value this way, so it can only be used to invalidate the op of a single wallet at a time. The wallet could similarly use a separate transaction to update its nonce and invalidate the op.

Your question does highlight an important point - that the client should treat the value as part of the account state, and not just the storage. I.e. if a wallet calls a function in a 3rd party contract, which doesn’t access storage but does attempt to send value, the clients shouldn’t accept it. Otherwise this could be used to invalidate ops of multiple wallets.

The EIP specifies this condition: The first call does not access mutable state of any contract except the wallet itself and its deposit in the entry point contract.
I now edited it to clarify that mutable state includes both value and storage. Thanks for bringing it up.

The simulation is performed against the latest state while building the block. It is the block proposer’s responsibility to ensure that an earlier transaction in the block doesn’t invalidate an op. The simplest way to do it is what you suggested - make the handleOps call in the first transaction in the block. A client could simulate it against a mid-block state or use access lists to prevent conflicts, but making it first is easier. The current client implementations already do this, but I now added a comment to the EIP to make this requirement clearer.

Thanks for highlighting these points! Client developers need to take them into account.

2 Likes

@tjade273 thinking some more about value-bearing calls, I realized that our current protection (dropping validations that change any account balance except the wallet and the entry point) is not good enough. The value-bearing call you suggested could be a self-call by some 3rd party account, so there’s no balance change. So the current protection won’t stop this DoS:

  1. Wallets call EvilContract.func() during validation.
  2. EvilContract.func() attempts to call its own receive function with 1 wei, reverting if it fails. When it has 1 wei it is not caught by the current protection because the balance remains 1 wei.
  3. Attacker sends ops from 1000 wallets with this validation function while EvilContract has 1 wei. Validations succeed and the ops are accepted to the mempool.
  4. Attacker tells EvilContract to send the 1 wei elsewhere.
  5. All ops fail validation in the 2nd simulation.

We’ll update the EIP to also ban value-bearing calls during validation, except from the wallet to the entry point.

Thanks again for your valuable comments.

@yoavw yes, this is essentially the attack I had in mind. I assumed that executing a value-bearing call in some sense triggered the

does not access mutable state of any contract except the wallet itself

in your last post but yes it bears being specific that this applies also to implicit reads via value-bearing calls.

Another interesting corner case I’ve been thinking about is:

Wallet calls C1, passing it say 500 gas. C1 attempts to call C2 (which just returns immediately). Since gas cost for first accesses is metered at a higher rate, the call to C1 succeeds exactly when C2 has been accessed before. So in simulation if the calls are simulated separately as they arrive at the mempool, they will behave differently from when they are all run together.

It seems like this can be used to form a large scale DoS, unless all the userOps are simulated together in a batch (is this the case?).

1 Like

The ops are also simulated together as eth_estimateGas of the entire handleOps transaction, but we do try to avoid cases where this simulation fails, because it creates more work. If it does fail, the simulation returns the index of the op in the bundle as an arg of the FailedOp error, and the bundler removes this op.

The method you described would indeed pass the single-op simulation and then fail the eth_estimateGas, which will cause it to revert with a FailedOp and specify that op and retry.

It’s not as effective as the previous attack (value-bearing self calls) since it only invalidates ops in the current bundle rather than the entire mempool, and the attacker ends up paying for the first op in every bundle because this one is valid and not removed. But it is still a nuisance to the bundler which has to eth_estimateGas multiple times and only gets paid for one valid op.

I wonder if we should mitigate. We could require all calls during validation to provide max gas, which would prevent this vector and hopefully shouldn’t break any valid use case. On the other hand, the attack doesn’t scale well for the attacker because it only causes some off-chain work while paying on-chain costs in each bundle. What do you think?