Pleasant UX in DApp authentication
With the community & user base growing and EIPs getting deeper feedback from more diverse contributors, design patterns finally seem to be emerging. We seem to be slowly entering an era of DApps with decent user experiences.
With the community & user base growing and EIPs getting deeper feedback from more diverse contributors, design patterns finally seem to be emerging. We seem to be slowly entering an era of DApps with decent user experiences.
We went through the process of designing onboarding and authentication for a few different DApps and I’d like to share the most elegant way I think we’ve handled that for Decenter projects. It may seem simple and obvious in retrospect but we’ve hit some walls a few times before finally getting to a smooth flow. I hope you find it useful.
While I’ll be focusing on browser injected wallets (like MetaMask), hardware wallets and others like WalletConnect tie in perfectly into this approach.
Quick EIP-1102 Intro
EIP-1102 is a browser wallet update bringing much-needed privacy improvements. Currently, any website you visit can read your wallet address. EIP-1102 proposes instead that a DApp needs to ask for permission to access the fully-featured provider. In short, this is done by calling ethereum.enable()
. The user will get a pop-up asking for approval, and the DApp can continue working as it did up until now.
The provider is no longer accessed through web3.currentProvider
. Instead, you should use the global ethereum
object:
window.web3 = new Web3(window.ethereum)
Mapping out the flow
We have a few scenarios to think about when a user opens our DApp:
- First-time user without a browser wallet (MetaMask)
- First-time user with a browser wallet
- Returning user
Here’s what we want:
- If we can, log the user in without any actions on their part.
- If user action is required, do not annoy the user with pop-ups on page load (as they haven’t yet explicitly asked to authenticate). Instead, wait for an explicit action.
- If no wallet can be detected (ie.
window.web3
orwindow.ethereum
), fall back gracefully and let them use the biggest possible portion of the app as guests.
Depending on your DApp, the third part may not be possible, which can make your implementation a bit simpler.
Let’s take a look at the technical side of the process:
- When the app loads, create a Web3 instance with a preset provider (ie. an Infura node). This is a fallback for users with no injected providers (wallets) as well as locked wallets and wallets on wrong networks.
- Attempt a “silent” wallet authentication. By silent I mean do not show the user any error pop-ups as they haven’t explicitly asked to be authenticated via their wallet, thus they haven’t done anything wrong.
- Let the user explicitly log in (aka add a “Log in” or “Connect wallet” button). This approach ties in perfectly if you support other wallet types like hardware wallets (take a look at nectar.community for example).
A caveat that should be considered: Recurring users might not notice they aren’t logged in if no visible warning is displayed. This can happen if their MetaMask is locked or their network is set to a wrong one. Make sure the user is aware they are not logged in and let them continue where they’ve left off once they connect the wallet. Also, keep in mind, currently changing the network in MetaMask reloads the page so that might break the flow! Though this behavior will also change on November 2nd.
Technical details
The actual login process goes as following:
1. Check if the user has approved your domain before. This is new in EIP-1102 and should go live in MetaMask on November 2nd. You can and should test it before then. If the user hasn’t approved the domain before and we’re doing a silent authentication, we stop the process right there.
const isMetamaskApproved = await ethService.isMetamaskApproved(); if (silent && !isMetamaskApproved) throw new Error(‘Provider not preapproved’);
2. Request approval if the domain is not preapproved and the auth is not silent:
await ethService.metamaskApprove();
3. Check the provider’s network.
const network = await ethService.getNetwork(); if (config.network !== network) throw new Error(`Wrong network — please set MetaMask to ${nameOfNetwork(config.network)}`);
4. Fetch accounts from the provider.
const account = await ethService.getAccount();
5. Finally, if all conditions are met, set the user’s address, do your post-login operations (like fetching balances) and optionally display a success message.
6. Make sure to catch all errors in the process.
try { // login process described above } catch (err) { ethService.setupDefaultWeb3(); if (!silent) showNotification(errorMessage, ‘error’)(dispatch); }
Here’s what the whole thing looks like:
// main component componentWillMount() { window._web3 = new Web3(config.providerUrl); loginMetamask(true); }
export const loginMetamask = silent => async (dispatch, getState) => { try { const isMetamaskApproved = await ethService.isMetamaskApproved(); if (silent && !isMetamaskApproved) throw new Error('Provider not preapproved'); await ethService.metamaskApprove(); ethService.setWeb3toMetamask(); const network = await ethService.getNetwork(); if (config.network !== network) throw new Error(`Wrong network - please set MetaMask to ${nameOfNetwork(config.network)}`); const account = await ethService.getAccount(); const balance = toDecimal(await ethService.getBalance(account)); dispatch(accountSuccess(account, 'metamask', balance)); showNotification(`Metamask account found ${account}`, 'success'); } catch (err) { ethService.setupWeb3(); if (!silent) { showNotification(err, 'error'); } } };
// ethService const isMetamaskApproved = async () => { if (!window.ethereum) return true; if (!window.ethereum.enable) return true; if (!window.ethereum._metamask) return false; if (!window.ethereum._metamask.isApproved) return false; return window.ethereum._metamask.isApproved(); } const metamaskApprove = async () => { if (window.ethereum) return window.ethereum.enable(); } const getNetwork = () => window._web3.eth.net.getId(); const getAccount = async () => { const accounts = await window._web3.eth.getAccounts(); if (!accounts.length) throw new Error(‘Wallet locked’); return accounts[0]; };
Edited isMetamaskApproved
to reflect namespace change for isApproved
as it is actually not a part of the standard (Oct 25th 2018)
You might notice we’re using window._web3
in the example code. This is done so we can switch between our provider and the injected provider freely. It might not be the prettiest way of doing it but it works fine, especially since all web3 interaction is kept in ethService
.
You might also notice I had to switch my provider to MetaMask because of the mentioned architecture of the ethService
. Because of that, I also need to revert the provider to the fallback one.
Thanks for the time. I hope you’ve found a part of this article somewhat useful. Please let me know about any additions you might have and feel free to share your experiences in improving your DApp’s onboarding.