August 23, 2019 Knowledge Center

How to: Integrate a crypto wallet into your dApp

This post by our developer Martynas gives a brief account about the unexpected complexity of integrating an Ethereum wallet into decentralized web applications.

We hope his observations and instructions will be educational and perhaps even save some fellow developer several hours of hairsplitting work.

When we first encountered a need to integrate a mobile wallet, I thought little of it. Surely, folks on the internet must have written plenty of guides by now. However, finding useful information about integrating wallets like Opera or MetaMask Mobile turned out to be quite a challenge. It took me some research to locate what I needed.

First, there was a forum where Ethereum devs discussed the interface that wallet providers should inject into JavaScript:
https://github.com/ethereum/interfaces/issues/16
And the result of that discussion is an EIP standard here: https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1193.md
There is also a standard for requiring access to a wallet: https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1102.md

These sources gave me a good idea about how to approach the task. Below is a short description of what the procedure should have looked like and how it turned out to be.

What should be injected into the browser?

In short, here’s what you should expect from wallet providers:

– They should automatically inject the window.ethereum global variable, which must provide these methods:

.send(rpcMethodName: string, params?: Array<any>): Promise<any> – this is the preferred and recommended way (by them) to call an RPC method as it returns the promise.
.sendAsync(payload, callback: (error, result) => void) – this is an optional alternative, only needed when provider internally uses a Web3 object prior to version 1.0.0-beta38.
.on(eventName, listener)– a method of the EventEmitter interface which allows listening for various provider events like “networkChanged”, “accountsChanged”, etc.
.removeListener(eventName, listener) – the same, but for removing the event listener.

– They should inject window.web3.currentProvider – which is essentially the same object as window.ethereum.

Of course, this does not prevent providers from adding more proprietary methods to an Ethereum object (more on this later).

The ways to use a provider

  1. We can use window.ethereum methods like .send, .sendAsync directly – these are just RPC call wrappers which do not provide any syntactic sugar. The RPC methods and params can be looked up here: https://github.com/ethereum/wiki/wiki/JSON-RPC
  2. We can construct a new Web3 object by taking the provider object as a parameter like new Web3(window.ethereum). And then we can use the usual methods – Web3 makes it convenient to call RPC methods, but internally it employs the same .send or .sendAsync from the passed provider.

Expectation vs. reality

My first try was, “Ok, let’s just use window.ethereum.send(...) as it’s recommended by the EIP.” The result: MetaMask returned an error in the console that “synchronous calls are not supported”, or something like that. It seems that MetaMask uses an old Web3 version and has an implementation where the send method still uses synchronous XML (which is bad and deprecated). However, the old schoolwindow.ethereum.sendAsync(...) works.

I also tried using the new Web3(ethereum.provider).eth.send(...). This would be my preferred method – it works, but with one drawback: if a transaction is rejected or some other RPC error happens, it returns an error as a string, where the error code is wrapped in some text, making it hard to parse. 

window.ethereum.sendAsync(...), however, returns errors as objects containing the errorCode property with an RPC error code – thus, making the UI interface informative. But… It’s only with Metamask. With Opera, errors are returned as some message strings, without any error code… Opera also does not implement those ethereum.on, EventEmitter methods, whereas MetaMask does.

The way which works for all wallet providers

After some more research and tinkering I managed to find an integration method that seems to avoid the quirks of individual wallet providers. Let’s call it ‘the universal method’ for the purposes of this post.

1. Get a provider instance

The provider should always inject a window.ethereum object. That said, I happened to hear that some old legacy providers inject it only to window.web3.currentProvider . Here is a catch-all way:

/**
* Gets current wallet provider instance
*/
export const getProviderInstance = () => {
 
 // 1. Try getting modern provider
 const { ethereum } = (window as any);
 if (ethereum) {
   return ethereum;
 }
 
 // 2. Try getting legacy provider
 const { web3 } = (window as any);
 if (web3 && web3.currentProvider) {
   return web3.currentProvider;
 }
 
 return null;
};

2. Enable the provider

As per https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1102.md, providers must expose the ethereum.enable() method (UPDATE: it is now deprecated—eth_requestAccounts should be used instead).

Metamask’s .enable() returns the promise with the currently selected account address, but Opera’s .enable() returns undefined.

Metamask also has a proprietary property ethereum.isMetaMask.

The universal method for enabling the wallet:

/**
* Enables wallet provider usage so it can be used or throws error otherwise
*/
export const enableWallet = async () => {
 const ethereum = getProviderInstance();
 if (!ethereum) {
   throw new Error('Your browser does not have Ethereum compatible wallet extension');
 }
 
 // Some web3 browsers needs enabling
 if (ethereum.enable) {
   let result;
 
   try {
     result = await ethereum.enable();
   } catch (e) {
     throw new Error('Could not enable Ethereum wallet', e);
   }
 
   // Metamask specific
   if (ethereum.isMetaMask) {
 
     // Metamask must contain array of accounts with at least 1 account after enabling
     if (!result || !result[0]) {
       throw new Error('There was an unknown problem while enabling MetaMask');
     }
   }
 }
};

3. Get the current account’s address

The EIP standard does not define this. Therefore, different providers implement it differently:

  1. window.web3.eth.accounts[0] – MetaMask mobile
  2. window.ethereum.selectedAddress – MetaMask desktop
  3. (await new Web3(window.ethereum).eth.getAccounts())[0] – Opera

UPDATE: In EIP-1102 it is written that accounts shall be retrieved using const accounts: await ethereum.send('eth_requestAccounts');

The universal method:

/**
* Gets current account address selected in metamask
*/
export const getCurrentAccountAddress = async () => {
 
 // 1. Try to get accounts from injected web3 object (legacy)
 const { web3 } = (window as any);
 if (web3 && web3.eth && web3.eth.accounts) {
   return web3.eth.accounts[0];
 }
 
 const provider = getProviderInstance();
 if (!provider) {
   return null;
 }
 
 // 2. Try metamask-specific account property
 if (provider.isMetaMask) {
   return provider.selectedAddress;
 }
 
 // 3. Try calling get_accounts rpc and take first entry
 const newWeb3 = new Web3(provider);
 const accounts = await newWeb3.eth.getAccounts();
 
 if (!accounts) {
   return null;
 }
 
 return accounts[0];
};

4. Send a transaction

As I’ve already mentioned, the method that works for all my tried providers and returns most adequate errors is ethereum.sendAsync(...).

The universal method:

import { toHex } from 'web3-utils';
 
// ...
 
/**
* Submits transaction using metamask and returns its hash
*/
export const sendTransaction = async (txConfig: TransactionConfig): Promise<string> => {
 const provider = getProviderInstance();
 if (!provider) {
   throw new Error('Your browser does not have Ethereum compatible wallet extension');
 }
 
 return rpcCBToPromise<string>((callback) => provider.sendAsync({
   method: 'eth_sendTransaction',
   params: [{
     gasPrice: toHex(txConfig.gasPrice),
     gas: toHex(txConfig.gas),
     to: txConfig.to,
     from: txConfig.from,
     value: toHex(txConfig.value),
     data: txConfig.data,
   }],
 }, callback));
};
 
/**
* rpcCBToPromise is a helper function that executes callback invoking function and returns a promise that
* gets resolved as soon as the callback function gets invoked.
*/
export const rpcCBToPromise = async <TResult>(fnExecutor: (callback) => any) => {
 return new Promise<TResult>((resolve, reject) => {
   const callback = (err, res) => {
     let finalErr = (res && res.error) || err;
 
     if (finalErr) {
       if (typeof finalErr === 'string') {
         finalErr = new Error(finalErr);
       }
 
       reject(finalErr);
     } else {
       resolve(res && res.result);
     }
   };
 
   fnExecutor(callback);
 });
};

NOTE: Metamask docs state that you have to send the gasLimit property for the gas limit, but I found out that it does not work. But if you use the gas property instead, the gas shown in the MetaMask dialog is then correct. So, the doc is probably not up to date on their end.

5. Get a receipt

I tried getting a receipt using the RPC call with ethereum.sendAsync('eth_getTransactionReceipt', ['0x123456789...']) – it works with Metamask, but Opera returns the error Invalid json request.

But I found out that it works when you construct your own Web3 object using the provider:

/**
* getReceipt returns transaction receipt
*/
export const getReceipt = async (txHash: string): Promise<TransactionReceipt> => {
 const ethereum = getProviderInstance();
 if (!ethereum) {
   throw new Error('You have to use Ethereum browser to get receipt');
 }
 
 const web3 = new Web3(ethereum);
 return web3.eth.getTransactionReceipt(txHash);
}

Conclusion

The slow creation of the EIP standard and the volatile implementation of Web3 have led to inconsistencies in Ethereum wallet provider implementations. Hopefully, the situation will get better in the future, but for now we have what we have.

Have a nice day!

M.A.

Share