ERC-4337: Account Abstraction via Entry Point Contract specification

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?

The attacker may be able to get away with less than 1 successful op per batch if they test for previous accesses to an account that is likely to be called regardless of the attacker’s userVerify activity.

For example, a common paymaster that is likely to be used early in the batch, or even the externally owned account of the bundler themselves.

I’m trying to reason through the expected burden on a bundler due to this: is there any rate-limiting we can do to prevent users from filling up the mempool with a huge number of these invalid userOps? If the attacker sends 100k requests with the same wallet but different nonces, do these all get added to the mempool and crowd out real transactions? If so, maybe some sort of wallet blacklisting is warranted.

No, each wallet can only have one op in mempool at a time. The attacker needs to use 100k contract wallets in order to send 100k concurrent ops.

But… it may be possible to implement your attack without actually paying for these deployments. When a new wallet is deployed via EntryPoint, its validation is immediately called and the deployment is reverted if validation fails. You could craft a wallet that almost always reverts its own deployment op, costing the attacker nothing.

So you convinced me, we should add a max-gas rule during validation. We already require the use of fixed gas since we ban the GAS opcode, so we might as well require it to be max.

Thanks & keep them coming! :slight_smile:

1 Like

I think even with the max gas limitation there’s a similar issue.

The attacker just needs to calculate and set the verificationGas such that C1 runs out of gas only when address C2 is not primed. Then if the call to C1 fails, pay the EntryPoint and if it succeeds, revert. There should be enough gas left over for this due to the 63/64ths rule (we can make the leftover gas as large as necessary by burning a bunch in C1).

One potential mitigation is to simulate the operation, then resimulate it with all of the called addresses primed, and make sure the contract pays in both cases.

Alternatively you could disallow all reverted calls, or just OOG calls, in the stack. This should work since the “real life” calls will always take at most as much gas as the simulated calls, so if no calls run out of gas in the simulation then they shouldn’t in “real life” either.

I’m not sure the 63/64 rule leaves enough gas to do anything in this case, since nodes won’t accept a high verificationGas op due to the risk of unpaid work. But you’re right, there’s a risk that this could be exploited, and the max gas change doesn’t mitigate it.

That won’t solve the problem either, because the contract could use a combination, expecting some addresses to be primed and others not. E.g. succeed if no addresses are primed or all addresses are primed, but fail if 2 are and 3 aren’t.

I think this is the way to go. The client should drop the op if there’s an OOG revert in any context. Thanks for suggesting that.

PEEPanEIP-4337: Account Abstraction via Entry Point Contract specs. with @yoavw @kristofgazso

5 Likes

Reference to another great presentation about this proposal:

Slides: ETHAmsterdam ERC 4337 - Google Slides

Main links from the slides:
Contract code: https://github.com/eth-infinitism/account-abstraction/tree/main/contracts
Audit blog post: EIP-4337 - Ethereum Account Abstraction Audit - OpenZeppelin blog

5 Likes

Do I get it right from the implementation here that:

  1. The user is required to have a prefund of callGas + verificationGas + preVerificationGas. Link. (Let’s discuss only the case when without the paymaster).
  2. At the same time, verification is allowed to consume the entire verification gas. Link.
  3. Also, the execution is allowed to consume the entire callGas. Link.
  4. The preVerificationGas is always paid entirely as it takes into account gas needed for posting calldata etc. Link.

These parameters do not take into account additional overhead for creating auxiliary variables on the stack, etc. The preGas variables carry this overhead and is taken into account when paying fees for the beneficiary.

It seems like it is possible that the user’s operation consumes the entire verificationGas and executionGas, and so the user does not pay for the overhead mentioned above (e.g. these operations are done at the expense of the EntryPoint contract).

The code is taken from the OpenZeppelin’s report.

The preVerificationGas is supposed to cover this (yes, its name is a bit misleading)
We assume that all the variable costs are calculated using the “gas diffs” and that the overheads you describe are constants, and thus can be calculated by the bundler, to verify the preVerificationGas pays for them.

Thank you @dror for your response. Could you please help me with what step of reasoning am I wrong here (let’s assume that the tx was successful and there is no paymaster):

  1. Here we create UserOpInfo with preOpGas being equal to op.preVerificationGas + preGas - gasleft(). Let’s call say that preGas - gasleft() = op.verificationGas + k , where k is some constant. That means that preOpGas = op.preVerificationGas + op.verificationGas + k.
  2. Here when actually execute the tx, the actual cost is returned. The preOpGas is added to it, and so, in the worst case, the returned actual gas is equal to at least op.callGas + preOpGas = op.callGas + op.preVerificationGas + op.verificationGas + k.

The refund that the user was required to have at the start of the transaction is op.callGas + op.preVerificationGas + op.verificationGas, but the actual gas spent was op.callGas + op.preVerificationGas + op.verificationGas + k. The question is, who pays for k? From here we subtract the actualGasCost from prefund, so if the prefund is smaller than the actual gas cost, then the transaction will revert.

Indeed, the EntryPoint will not lose any money, but the operator should always remember that the operation is never guaranteed to succeed unless the verification step took so little amount of gas that the difference is enough to compensate for the k

The EIP states that:

To prevent replay attacks (both cross-chain and multiple EntryPoint implementations), the signature should depend on chainid and the EntryPoint address.

This makes sense given EIP-155.

Just so it’s clear for me, the reason the EIP doesn’t define how this signature is calculated over the data is because any signing and hashing algorithm (within reason) can be used?

For example, to calculate the hash of a message before creating a signature in Ethereum it is common to do the following:

Keccak256("\x19Ethereum Signed Message:\n32" + Keccak256(message))

So is the reason this ERC isn’t prescriptive like the example above due to the flexibility it offers with respect to crypto algorithms?

Therein, the validateUserOp on the wallet contract must make sure that it’s accounting for the chainId and the EntryPoint (as well as the UserOperation) when checking the signatures validity.