This post focuses on the tradeoff between security and UX associated with supporting
delegatecall for HatsWallet.
HatsWallet is a smart contract account for every hat, built with ERC6551.
There are two versions of HatsWallet that differ by security model
1-of-N HatsWallet is a basic implementation of ERC6551, where any wearer of the hat can unilaterally control the wallet. This is great for hats with a supply of 1. For hats with supply >1, its useful in some scenarios but often the 1-of-n security model is too risky for management of valuable assets or important onchain authorities.
M-of-N HatsWallet (still in development) addresses this risk by adding an approval threshold: M hat wearers must agree before a tx can be executed. However, in order to do so, it needs to store some data about proposed / queued txs. This opens a potential attack vector for a malicious contract that is
delegatecalled by the HatsWallet.
We need to consider the tradeoff between the UX benefits of delegatecall vs. its security risks.
delegatecall enables significant flexibility for a smart account:
- Execute logic from other contracts or libraries as if from the smart account itself
- Most prominently, this includes the ability to batch together multiple arbitrary calls to other contracts by delegatecalling to an external multicall/multisend library.
(2), for example, is how the Safe handles batch tx execution. The Safe.sol contract doesn’t know about the multisend library, but since the Safe’s
execTx() function supports delegatecalls, the Safe ui can batch together multiple calls into a single tx’s calldata, where the target contract is the multisend library.
Crucially, an alternative ui that supports safes could use a different multisend library, such as Multicall3.sol, and that would also be compatible with Safe.sol. In other words, arbitrary/unconstrained delegatecall creates flexibility and simplifies composability at the app layer.
delegatecall comes with some pretty big security risks for a smart account. A malicious delegatecalled contract could…
Alter mutable values in the smart account’s storage, including the
ERC6551Account.statevariable, other configuration parameters (such as smart account plugin authorization), or queued tx status. This is called a storage collision attack.
Steal any native ETH (or the native chain asset) in the smart account via
- The more apps that support HatsWallet, the more value it will have for users of Hats Protocol
- The easier it is for apps to support HatsWallet, the more apps will do so
- The less opinionated HatsWallet is about how txs are executed — or in other words, the closer HatsWallet adheres to existing standards — the easier it will be for apps to support HatsWallet
- Therefore, the ideal design — security considerations aside — for HatsWallet batch txs would be to support unconstrained delegatecalls, leaving apps to decide for themselves which multicall/multisend library to use
Of course, we do need to take into account security considerations. And given the significant risks posed by delegatecall, I don’t think its appropriate to support it without some sort of safety constraints. Otherwise, we put too much onus on users to be constantly vigilant about the contracts they — or apps on their behalf — are delegatecalling.
selfdestruct attack impacts both flavors of HatsWallet. But the biggest concern is for M-of-N HatsWallet, since stored proposed/queued tx data would be particularly vulnerable to storage collision attacks. Imagine a malicious contract deleting a proposed tx, modifying votes for a proposed tx, or even adding a proposed tx of its own. Each of these would be at best harmful to the coordination efforts of the hat wearers, and at worst result in significant loss of funds or manipulation of onchain permissions.
We want to balance UX flexibility with security. So what are our options?
Add a built-in
executeBatch function that can execute a batch of txs without the need for delegatecall. This is a relatively simple and clean approach, but it does rely on apps to support it.
Tokenbound has implemented a nice concept that attempts to enable the full flexibility of arbitrary delegatecalls without the security risk. The basic idea is to have delegatecalls originate from a sandbox account deterministically deployed and controlled only by the main account.
For executing batch txs, this can work with any external multicall/multisend library. The neat trick is that the main account has an
extcall function that the sandbox account can call. We can use this to re-route arbitrary calls back through the main account, without the main account being exposed to
selfdestruct or storage collision attacks.
It does have a few drawbacks:
- this approach is novel and relatively new, so it may be worth seeing how it fairs in adversarial environments before adopting it.
- there is some extra gas cost associated with the two additional layers of nested calls (calls 2 and 4a/b in the picture above)
- like (A), it also relies on apps to support it.
Each account could include an allowlist of approved contracts that can be delegatecall’ed. This could include popular/common multicall libraries.
While this would work well in the short term, what if those libraries become obsolete? We could make the allowlist mutable, but then who controls it? All I can say when I think about the potential issues here is “yikes”.
Another approach is to make less functionality vulnerable to storage collision attacks by using less storage. In theory, we could borrow from Safe.sol’s approach and rely fully on offchain signatures to handle proposed/queued txs. This would have the ancillary benefit of gasless signatures for most user interactions.
The big drawback of relying on this approach is the large overhead created for apps, which would have to handle storage of offchain signatures. Doing this right is not an easy undertaking. Safe had to build an entire centralized API service dedicated to this purpose.
Given the above considerations, my current best thinking is to block arbitrary delegatecalls and implement executeBatch, ie Option A.
As the delegatecall sandbox receives more attention and confidence in its soundness grows, we can add it into the implementation, ie Option B gets added in a future version.
As the first version(s) M-of-N HatsWallet gets more usage and we learn more about its value, we can consider adding support for fully offchain queueing and signing as well, ie consider Option D for a future version.
Please weigh in!
- What is this analysis missing?
- Is there anything I’m not considering?
- What would you like to see in HatsWallet?