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
That means that there is no way to enforce that the miner (or the one who submits this bundle of
UserOperations) will get paid.
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)
preVerificationGasis 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.
- The UserOp contains 2 other gas values, the
callGas. The user/paymaster must have balance to pay these values, but eventually pay only for the actual used gas. The
preVerificationGasis paid in full.
preVerificationGasname is misleading a little: most of its value indeed comes before the verification, but it covers also some static overhead that comes later.
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?
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
Alright, I get the flow know and was able to follow the steps in the code 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
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.
@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:
- Wallets call EvilContract.func() during validation.
- 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.
- 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.
- Attacker tells EvilContract to send the 1 wei elsewhere.
- 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:
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?).
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
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!
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
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.
Reference to another great presentation about this proposal:
Main links from the slides:
Contract code: account-abstraction/contracts at main · eth-infinitism/account-abstraction · GitHub
Audit blog post: EIP-4337 - Ethereum Account Abstraction Audit - OpenZeppelin blog