Adventures with Account Abstraction – Risks and Mitigations in __validate__
Starknet is at the forefront of account abstraction, having baked it directly into the protocol.
One of the great benefits of account abstraction is the ability to provide custom validation logic on a user’s account, such as biometric validation of transactions and daily withdrawal limits.
The way it works is that when a transaction is sent to the network, it is validated with the sender account contract’s __validate__ entry point, where, amongst other things, the signature is validated.
Once validation has passed successfully, the __execute__ entry point is called and runs the actual transaction.
Risks with __validate__
Ideally, you would implement arbitrary validation logic as part of the __validate__ code, but this opens up potential DOS attack vectors.
Since transactions that don’t pass validation are REJECTED before execution, they do not incur any fee. This means that an attacker can deploy a very expensive __validate__ implementation that wastes a lot of resources and then simply fails – incurring high computational cost with no fee attached to it.
Mitigation of risks
Starknet takes several counter-measures to mitigate the above DOS vector.
The most straightforward and intuitive measure is to limit the computation in __validate__. Specifically, to limit the number of steps that the transaction validation may run.
Once that limit is hit, validation fails. This limitation caps the potential amount of computation that can be performed without a fee.
REJECT transactions at the gateway
Another counter-measure is to not even include transactions that do not pass __validate__ in the sequencer queue. In fact, this change is implemented in Starknet version 0.12.1.
When a transaction is submitted, the __validate__ entry point of the issuing account contract will be called at the gateway. If validation fails, the transaction will be rejected immediately without further processing.
This is beneficial because, once a transaction passes the gateway validation check, it will be processed by the sequencer, whose computation capacity is a much scarcer resource than the gateway’s computation capacity.
But wait: how can the gateway be sure that the transaction is actually invalid? What if the transaction validation depends on some external state?
Another, less trivial limitation is to block call_contract syscalls from within __validate__.
Now you might ask yourself: why do we need this limitation once computation is capped and invalid transactions are rejected at the gateway?
To explain this limitation, we need to assume that:
- The call_contract syscall is allowed in __validate__
- MEV is possible on Starknet (at the time of writing, this is not yet the case in Starknet version 0.12.1 as transactions are processed sequentially).
- Rejecting transactions is much cheaper at the gateway than at the sequencer.
Given these assumptions, consider the following attack:
- An attacker deploys N account contracts, all of which implement a __validate__ function which is right at the limit of the computation allowed.
- Towards the end of the malicious __validate__ logic, the attacker queries some state in some other contract, using call_contract. This causes __validate__ to either succeed or fail according to the response true / false respectively.
- Now the attacker submits T transactions from each of the malicious N account contracts, meaning NxT transaction overall. All of them pass the gateway, as the attacker made sure at the time of transaction submissions that the call_contract would return true.
- Right after that, the attacker submits a transaction that changes the call_contract response to false, trying to utilize MEV so that this transaction will be executed prior to the NxT transactions from the malicious Account Contracts.
- If the attacker is successful, and they are able to change the state from true to false prior to the NxT transactions, then with a single transaction the attacker will be able to create NxT REJECTed transactions, utilizing the maximum computation, without paying any fee as the transactions fail __validate__ during execution. REJECT will happen on the sequencer, which, as stated, is a more limited and scarce resource in the network.
So, by REJECTing the transactions at the gateway level, as well as preventing the dependency of __validate__ on an external state via the call_contract, we mitigate the risk of an attacker being able to queue up a lot of transactions that pass the gateway and REJECT on sequencing, incurring a lot of overhead on the network without accruing any fee.
In the next installment of this series we will show you how to implement the state-of-the-art validation flow of the Multi Owner Account, another key possibility of account abstraction, while taking these limitations into account. So join us on our next AA adventure.
Yoav Gaziel is co-founder of Braavos, the first wallet designed specifically for Starknet, and previously spent 10 years as a CTO for two prominent Israeli startups.