How we built our smart contract wallet
When we released CryptoKitties nearly two years ago, we knew there was going to be a serious barrier of entry to play for folks that were new to crypto. Players would have to create a digital wallet (a new concept to most folks) and put up cash for a “cryptocurrency” (another new concept for most folks) before they could even try the game out.
Dapper, the smart wallet Dapper Labs built (and continues to build), showcases the lessons CryptoKitties’ users taught us.
As early CryptoKitties players gave feedback, we learned that the barriers to blockchain were even higher than we’d anticipated:
- there was the process of backing up a key or seed phrase (usually accompanied by frightening warnings about the catastrophic consequences should you ever lose it),
- the challenge of understanding the mechanics of gas, and
- figuring out why on Earth in-game (and on-chain) actions sometimes took a minute and sometimes took an hour.
Mobile apps that users pay upfront to use have the same problem of requiring users to pay for something before they can try it—as a result, the number of these mobile apps has dwindled drastically. Now, most dapps have inherited this usability problem.
In addition to the onboarding friction, the security situation was also dire. At the time CryptoKitties launched, most wallets fell into one of two camps:
- Client-side wallets — Wallets that store keys on the user’s device (eg. Metamask)
- Server-side wallets — Wallets that store keys on the service provider’s server (eg. Coinbase)
From a developer’s perspective, client-side wallets are simple, and they map well to the existing Ethereum account model. But by construction, they’re systems with a single point of failure. If you lose your seed phrase, you’re out of luck. If someone steals your seed phrase, you’re out of luck. If someone manages to find the backup of your seed phrase you scrawled on the back of a napkin… you get it. When a system is designed so that a single mistake results in unrecoverable compromise or loss, it’s not a matter of if this will happen, but when.
When a system is designed so that a single mistake results in unrecoverable compromise or loss, it’s not a matter of if this will happen, but when.
Server-side wallets, on the other hand, cushion user error because the service provider handles the complexity of securing your account — there are no keys for you to worry about, you can happily forget your password every single day, and you never need concern yourself with the intricacies of transaction fees.
Unfortunately, by making things easy for their users — storing all the keys to clients’ assets in one place — these service providers also make things easy for hackers, as the short but voluminous history of server-side wallet compromises shows. Hence the saying in the blockchain space, “not your keys, not your crypto.”
When CryptoKitties launched, there were some multi-sig wallets around, but they were complicated to set up and use. If using a client-side wallet was like learning to ride a bicycle without training wheels, using a multi-sig wallet was like learning to ride a bicycle underwater.
Faced with all this, we decided to tackle the problem of a secure, user-friendly wallet ourselves.
After months of studying different wallet models, we narrowed in on some fundamental design principles that would set our foundations:
- No single point of failure — The design should expect and be resilient to user error (eg. key loss, key compromise, etc.)
- End user is always in control — The user should always be in control of what’s in their wallet; Dapper Labs should never have access to or control over their assets
- Ease-of-use — The wallet should be easy, intuitive, and fun to use
Smart Contracts vs Direct-Keyed Accounts
Smart contract wallets add some complexity, but in exchange, they offer vastly superior security, flexibility, and usability. Before getting too far into the weeds, let’s discuss smart contract wallets in general and how they differ from the more typical direct-keyed account (DKA).
DKAs generate a key pair: one public key and one private key. The public key corresponds to the DKA’s Ethereum address. The private key authorizes actions for the account by signing a transaction describing the action (send 1 ETH to Bob, buy a CryptoKitty, etc.). The DKA model is simple but retains the security and usability flaws discussed earlier.
In a DKA wallet, control over the account is inextricably bound to a single private key — a single point of failure. Because Ethereum requires that the account that sends a transaction also pays for the transaction, users must put money into their account before they can do anything, and they have to deal with seesawing gas limits and transaction fees.
The difference with Dapper
The smart contract at the heart of Dapper executes three primary functions: transacting, authorizing, and recovering.
- Authorization enables deciding which devices have access to your account and which ones don’t — did you lose the phone containing your Dapper wallet? No problem, just revoke that device.
- Transacting is, well, transacting, but with some superpowers—you can perform multiple distinct actions within one transaction, and cosigner keys enable powerful security features.
- Recovery is your wallet’s last line of defence— it allows you to get back into your wallet, even if you lose all the other keys to your account.
And, to prepare for the future, Dapper supports two methods of extensibility, accessory contracts and static delegate contracts, which we’ll discuss in greater detail later.
Dapper’s authorization system
Dapper’s authorization system determines which keys can access the wallet and enables extending Dapper functionality with additional smart contracts. Dapper is a multi-sig wallet, which means submitting a transaction requires more than one key to sign off. A group of keys that, when combined, can sign off on a transaction is called an authorized group. Dapper can be configured with any number of authorized groups, and each authorized group can contain one or two keys.
An interesting aspect of authorized groups that opens up compelling possibilities is that one of the keys in an authorized group doesn’t need to be a key at all. Instead, it can be another smart contract with its own complementary logic — we call this an accessory contract. There’s an entire section devoted to this later on, but for now suffice it to say that it makes Dapper very configurable while keeping the base contract simple. (A quick note on terminology here: although authorized groups can contain either keys or contracts, I use the term “key” when speaking generally throughout this post.).
There are two actions in the contract related to authorization: adding an authorized group and revoking an authorized group. These are both accomplished by calling the same function.
function setAuthorized(address _authorizedAddress, uint256 _cosigner)
_authorizedAddress == _cosigner, this indicates that there is no second key (ie. creating an authorized group with just one key). If
_cosigner == address(0), this revokes authorization of
_cosigner, which is an address, is represented by a
address types are only 160 bits (but are stored using 256 bits anyway), using a
uint256 enables us to actually make use of those upper 96 bits for metadata purposes. (That’s a lot of bits!)
In the typical configuration, we call the first key the “device key” and the second key the “cosigner key.” The device key is stored on an end-user’s device and the cosigner key is stored by a service provider (in the case of Dapper, this is the Dapper API). This enables a range of features that aren’t possible with most other designs (certainly not with the same security properties). For example, we can not only simplify transaction fees for users by paying for gas via a cosigner key, we can also monitor in-progress transactions and dynamically adjust gas prices to optimize time-to-mine and costs.
Almost any kind of fine-grained access control restriction is possible using the cosigner key as the enforcer. Think features like automated fraud detection, per-device daily limits, and dapp-specific spend limits.
Beyond access control, one of Dapper’s most compelling use-cases is empowering users to take more control and responsibility for their wallet as time goes on. I like to refer to this property as “flexible sovereignty.”
Because authorized groups can be revoked as well as added, users can start their blockchain life with their keys fully managed by a third party. As their needs change and they learn more, they can adjust their security/convenience trade-off by re-configuring Dapper. They can set up an authorized group with one device key and one service provider key (as the Dapper product is currently set up), or create an authorized group entirely under their control, just by authorizing and revoking the appropriate keys. The wallet’s address doesn’t change, no assets need to be transferred, and no key material is reused.
In the course of designing Dapper we ended up overloading the term “transaction” to the point of collapse, so we were forced to use more specific terminology.
- An operation is a single user action executed by the wallet (this maps to the notion of a transaction in direct-keyed account wallets).
- An invocation is an ordered list of operations to be executed together in a single function call to the wallet contract.
- A transaction is what the term typically means when speaking generally about Ethereum. To sum up, a transaction contains 0 or more invocations and an invocation contains 1 or more operations.
Transacting represents the day-to-day operation of the wallet. Compared to DKAs, Dapper has two superpowers when it comes to day-to-day transactions. First, transactions can be submitted (and consequently transaction fees can be paid) by anyone, so long as the proper signatures are included. Second, Dapper can bundle any number of transactions together to be executed serially, with optional atomicity requirements (an all-or-nothing option where every transaction in a bundle either succeeds or, if one transaction fails, they all do).
The ability to perform multiple operations at once saves time and transaction fees by spreading the per-operation overhead across the bundle. Smart contracts impose a moderate per-transaction overhead above the standard 21,000 gas when performing one operation at a time. But bundling negates this overhead when grouping as little as two operations together because the per-transaction overhead is spread over all the operations in the bundle. The transaction fee overhead of Dapper becomes more efficient than a DKA after 4 bundled “Send ETH” operations or after 2 bundled “Send ERC721/ERC20” operations.
There are 4 ways to submit an invocation to Dapper, each with their own function:
function invoke0(bytes calldata data)function invoke1CosignerSends(uint8 v, bytes32 r, bytes32 s, uint256 nonce, address authorizedAddress, bytes calldata data)function invoke1SignerSends(uint8 v, bytes32 r, bytes32 s, bytes calldata data)function invoke2(uint8 calldata v, bytes32 calldata r, bytes32 calldata s, uint256 nonce, address authorizedAddress, bytes calldata data)
The number after the word “invoke” in the function names indicates how many signatures are passed in explicitly as function parameters, as opposed to implicit signatures (ie.
msg.sender in the context of the transaction execution). Each of these functions accepts a
data parameter that encodes the revert flag followed by a list of operations (format below).
<revert 1byte>[<target 20bytes><value 32 bytes><datalen 32 bytes><data variable bytes>][…]
invoke0 only works for authorized groups of size 1, where the single key sends the transaction invoking the wallet. The function verifies the signature using
msg.sender, then executes the operation(s).
invoke1SignerSends are for the typical case where the authorized group contains two keys, and one of those keys will sign the transaction invoking the wallet. If the cosigner key is the one signing the transaction,
invoke1CosignerSends is used and the device key’s signature is passed in explicitly as a parameter to the function.
invoke1SignerSends works the same way, but for the case where the device key is signing the transaction.
invoke2 is also for the typical case where the authorized group contains two keys, but it enables anyone who so desires to sign the transaction invoking the wallet. This enables certain use cases like, for instance, dApps paying the transaction fees for their users. Both primary and cosigner key signatures are passed in and validated explicitly.
Like DKAs, each authorized group has an associated nonce that is incremented each time an invocation authorized by that authorized group is executed.
Dapper’s recovery mechanism
Dapper’s recovery mechanism provides a safety net in case you lose access to all your authorized keys. It consists of a single, highly-permissioned key called the recovery key. The recovery key is the only key that can perform a recovery, which will revoke access to all authorized groups and authorize a new group to replace them.
There are two public functions relevant to the recovery mechanism:
function setRecoveryAddress(address _recoveryAddress)function emergencyRecovery(address _authorizedAddress, uint256 _cosigner)
setRecoveryAddress function is callable by any authorized group and (no surprise here) sets the address of the recovery key. The
emergencyRecovery function is the only function callable by the recovery key. It performs the recovery, revoking access to all existing authorized groups and setting the new
_cosigner in the same manner as the
To save on execution cycles and storage, we store the authorized groups as a mapping where the key is prefixed by an
authVersion. In the same way that the extra bits of the cosigner are used to store metadata, the extra bits of the device key (the key in the mapping) are leveraged as a versioning prefix. When we read from the mapping, we prefix the
cosigner = authorizations[authVersion + authorizedAddr]. That way we can effectively revoke all active authorized groups simply by incrementing the
authVersion, whether the user has one device or 100.
As a handy side effect, this leaves behind unused storage in the form of revoked authorized groups. Freeing up storage offers a gas rebate, and we expose a function to take advantage:
function recoverGas(uint256 _version, address calldata _keys)
Anyone can call this function to recover gas by freeing up any storage still in use by revoked authorized groups, so long as they specify which keys to delete.
As Ethereum evolves over time, smart contracts will need to change to support new flows and features. Dapper has two avenues for future extensibility: static delegates and accessory contracts.
Delegates are external contracts that can be allowed to implement read-only functions on behalf of a Dapper contract. When Dapper encounters a function ID it doesn’t know, it checks an internal mapping of delegates for a match. If a match is found, it forwards the call to the delegate using staticcall to prevent, for security reasons, the delegate from making any state changes. This is meant to enable support for new EIP requirements similar to ERC721’s
onERC721Received, which returns a magic value to indicate support for ERC721.
Dapper also supports accessory contracts, which are contracts configured as one of the “keys” in an authorized group. Since Dapper stores authorized keys as addresses, they don’t actually need to be keys. They can just as easily be other smart contracts simply by calling
setAuthorized with the address of a smart contract rather than of a DKA.
By hooking additional smart contracts into the authorization scheme, we can keep the Dapper core contract small and simple to reason about while enabling opt-in support for all kinds of more complex authorization flows. You could implement arbitrary N-of-M multi-sig by implementing the N-of-M part in an accessory contract. You could implement on-chain spend-limits, dApp-specific transaction limits, token-specific protections — pretty much any kind of transaction validation that’s expressible as Solidity code.
If you’ve been paying close attention, you may have noticed there’s an unresolved tension between the “no single point of failure” design goal and the role of service providers that hold cosigner keys: if the service provider goes dark or turns out to be malicious, they can perform denial of service attacks on their users simply by refusing to sign transactions. This is very much a single point of failure, but there are ways around it.
One way is for the user to create an authorized group that only they have access to, and then to store that key somewhere safe (given most users aren’t willing to have more than one backup, this will probably be a 1-key authorized group). If something goes wrong with their service provider, they can use this backup to recover access to their wallet.
But, assuming the typical case of a single backup, we’re back to the single point of failure problem. The escape hatch is an accessory contract concept that enables users to “escape” from their service provider if they ever need to. It does this by interpreting the wallet’s internal nonce as a signal of malfeasance on the part of the cosigner key holder.
Users can submit a challenge to an Escape Hatch contract using their device key that notes their current nonce and starts a timer. If the timer runs out and their nonce hasn’t changed, this is interpreted as a signal that the cosigner keyholder is refusing service to the user, and the Escape Hatch will revoke the cosigner key’s access, allowing the user to recover access to their wallet.
Dapper makes it safe and easy to access blockchain-based fun
Dapper Labs’ mission is to bring the first billion to blockchain. To do that, blockchain needs to be easy and secure to use for your everyday person. So we set out to make Dapper the simplest, safest, and most enjoyable wallet possible, and we harnessed every aspect of smart contract technology to make that happen.
We believe that blockchain is already changing the world, and everyone should be a part of that. That’s why, with Dapper, we take the blockchain from esoteric to everyday.