How to make a user-friendly Ethereum dApp

ethereum
uiux
dapps
metamask
userfriendly

#1


via Bloom

Providing a good user experience is fundamental to building good products. This is one of the most important aspects that drive user adoption and determine the products that people love. At Bloom, we aim to create amazing user experiences that satisfy the end-user (you)!

While it’s not obvious at first glance, it’s the little things that really count in driving amazing user experiences. User experience specialists focus on things like reducing page load by 50ms and turning “three click” product interactions into “two click” product interactions. If you look at conversion funnels, you’ll see that every additional bit of work the end user has to do, ultimately results in a portion of users giving up part way through the flow.

dApps have room to grow on this front. Most dapps are still clunky and hard-to-use.

For example, imagine you’re creating a decentralized professional social network. What steps does a user who doesn’t own Ethereum need to do in order to sign up and invite another user to connect?

  1. Visit your signup page and fill out some basic signup information (email, name)
  2. Install Metamask
  3. Sign up for Coinbase
  4. Buy Ethereum
  5. Transfer that Ethereum to their Metamask
  6. Create an account within your smart contract
  7. Wait for that transaction to mine
  8. Invite their acquaintance to connect via your smart contract and wait for that to mine too

As the cherry on top, each transaction they submit to the blockchain prompts the user with an intimidating and opaque popup:

1_9RnulgD1KWLCcRSJW-2g0g

That is optimistically a 30 minute process that involves installing an extension, signing up for another website, and spending money.

At Bloom, we’re focused on making our dApp user experience simple and easy. In this post, I share some techniques we’ve had success with and how we’re looking to improve even further.

Explaining Transactions

Often times, the design needs of your dApp are at odds with the security requirements of third party wallets like Metamask. In some ways this is obvious — they can’t just share the private key with your website because malicious websites would use that to steal a user’s ETH. Other implications are less obvious — for example, your dApp isn’t allowed to display explainer text underneath the transaction dialogue because a malicious website could put whatever text they want there too. In other words, we can’t tell Metamask to say “This transaction casts a vote in Bloom’s poll” for our poll transactions because then a malicious site could say the same, but actually steal from us.

Whenever the user is going to use Metamask in a new way, our app should explain what we’re doing. Here is what it looks like for a user to vote in a Bloom poll:

Before Metamask opens, we are able to tell the user:

  1. You’re casting a vote.
  2. We’ve set the gas price and limit for you.
  3. You don’t need to change those values. We figured it out for you.

This is a big improvement! Plenty of users aren’t familiar with what gas is and whether they can edit it. Explaining before they see Metamask lets them know things are working correctly.

It is important to have the user click “continue” before they actually see Metamask. Showing the modal at the same time means the user might read Metamask first or miss the popup by focusing on the modal. Even if they see both, they might try to figure out the Metamask dialogue first and not read the instructions in time.

Managing Transactions for Users
In our most recent product release for Bloom, we added the ability to verify your phone number via SMS and save the verification to the blockchain. This involves two Ethereum transactions, but the end user doesn’t pay anything in gas!

We do this by using the signTypedData API introduced in EIP #712. Metamask exposes this feature which means you can use it in your dApp.

Remember that professional social network example from the start? If we design that dApp around users signing data to approve operations, we can simplify the experience:

  1. Fill out some basic signup information (email, name) like they would for a normal website
  2. Install Metamask
  3. Click “sign” on a window that says they are creating an account
  4. Click “sign” on a window that says they are inviting another user to connect

The user isn’t submitting the transactions, the backend of our app is, so they don’t have to wait for the Create Account transaction to mine before using the Invitation to Connect feature. As long as our backend verifies the signatures are correct and from the right user, we can ensure the transactions are submitted in the correct order on the backend. Using signTypedData also makes the Metamask dialogues comprehensible!

Implementing this is pretty straightforward in our smart contracts. Here are the important parts:

contract EthedIn {
  // Digest describing the data the user signs.
  // Needs to match what is passed to Metamask.
  bytes32 public constant delegationHash =
    keccak256("string Action", "address Address");

  // Create account for a user using the signature they provided
  //
  //   * Only approved transaction delegators are allowed to call this
  //   * _sender should be the address included in the signature as well
  //     as the address that would be submitting this transaction
  //     if it were not delegated
  function createAccountFor(
    bytes _delegationSig,
    address _sender
  ) public onlyTxDelegator {
    // Recreate the digest the user signed
    bytes32 _delegationDigest =
      keccak256(delegationHash, keccak256("Create Account", _sender));

    // Recover the signer from the digest.
    address _signer = recoverSigner(_delegationDigest, _delegationSig);
    // Check that it matches the claimed sender
    require(_sender == _signer);

    // Call the actual create account function
    createAccountForUser(_sender);
  }

  // Actually create the account
  function createAccountForUser(address _address) private { /* ... */ }
}

Once you get it working once, it is easy to adapt to any user facing function you need. A few notes here:

  1. You don’t have to include the Action part, but I like that it helps describe to the end user what is going on
  2. The signTypedData API is nice because the schema describing the data is hashed before the data itself which means we can precompute this value in our contract and store it as a constant. This reduces computation inside of transactions
  3. The createAccount function is special because we can reuse the _sender as the assumed _address in the signature. If we were writing inviteToConnect then the params would be longer. Something like inviteToConnect(string _message, address_to, bytes _delegationSig, address _sender);
  4. For brevity, I didn’t include the implementation of recoverSigner. Look into ECRecovery if you’re interested in this.

Creating our createAccount in Metamask would look like this:

const user = web3.eth.accounts[0];

const sigInput = [
  { type: "string", name: "Action", value: "Create Account" },
  { type: "address", name: "Address", value: user           }
];

web3.currentProvider.sendAsync(
  { method: "eth_signTypedData", params: [sigInput, user], from: user },
  handleSignature
);

Keeping Things Decentralized

Building products for the crypto community is tricky — there are a lot of vocal and opinionated users with their own ideas on how things should work. One tough tradeoff is usability vs. purity of decentralization. You’ll see both of these types of users:

  1. New to Crypto — Why do I have to install Metamask? Why do I have to pay for gas? Why is this taking so long?
  2. Hardcore Decentralizer — I don’t want to use Metamask. Why can’t I just sign transactions offline with my Trezor? You don’t use [brand new decentralized technology]? Does that mean you’re basically centralized?

The signature oriented functionality makes the new-to-crypto user happy. You should add normal functions the end user can submit to the contracts so that the minority of hardcore users can do so. Adding the direct functionality for createAccount is simple:

function createAccount() public {
  createAccountForUser(msg.sender);
}

Future Work

At Bloom we’re trying to transition all of our existing dApp functionality to use what I’ve described in this post. Every wallet interaction should be explained and confirmed by the user. Smart contract interaction should be facilitated via signature oriented smart contract functionality with an escape hatch for the hardcore users.

Our north star for dApp development is to make our products feel like really satisfying non-blockchain products. Blockchain and asymmetric cryptography should only peek out from under the hood when it makes the app experience better. To give you an idea of what this means:

  • Don’t make the user wait to see if their transaction was mined
  • Don’t put Ethereum addresses at the center of the user experience when possible. For example, inviting a friend to Bloom shouldn’t involve the user asking their friend “what is your Ethereum address?”
  • Do use private keys for authentication instead of passwords. Private keys are better than risking users reusing passwords across the web.
  • Do embrace users having multiple addresses. If a user owns 10k BLT then they should be able to put that in cold storage while using a different ETH address for custodial transactions like voting in a poll.

While we’re very happy with the user experience improvements we outlined in the previous sections, we could do better. Here is what we’re working towards long term:

  1. Working without an extension — When it is out of beta, we would like to integrate Metamascara into our web dApp. This will defer the need to install a chrome extension so it doesn’t gum up the on-boarding flow. We can always prompt the user to install the extension down the road. If they do, the app should gracefully disassociate Metamascara addresses and replace them with Metamask addresses.
  2. Moving signatures behind the scene on mobile — We’re working on mobile applications for iOS and Android. When these are ready, they will reduce and eventually eliminate the need for an in-browser wallet for the user. With our own mobile app which manages private keys that are only allowed to be used to interact with Bloom, we’ll have a wallet experience that we can fully control. As a result, certain signatures can happen fully behind the scenes or be part of a normal button click. The user won’t even know when the blockchain is involved!
  3. Extend smart contracts to anticipate common flows — A big bottleneck in user experience can be introduced by requiring two Ethereum transactions back to back which depend on each other. For example, to verify your phone for BloomID the end user first needs to add a signature of their phone number and a nonce to our identity contract and then request a verification via our attestation contract. The second transaction depends on the output of the first so it can specify what identity information is being verified. Performing two verifications back to back is slow and it is confusing as the user to sit and wait for two minutes. We should build our contracts to anticipate these flows such that the user can just sign one thing and a batching contract will call both functions back to back.

By batching common product flows, moving transactions behind the scenes via mobile apps, and removing hard dependencies on browser extensions from the on boarding experience, we’ll get much closer to that north star we want.