The WalletConnect Report is here!
Download report
Blog Home
Tutorial
|
March 27, 2024
Profile picture for Boidu
Boidushya Bhattacharyay
Create a javascript wallet with Web3Wallet
Learn to build a user-friendly wallet with ease with Web3Wallet

The world of web3 applications has been growing at an unprecedented rate, offering users new ways to manage their digital assets and interact with various protocols. However, as the ecosystem expands, the need for secure and user-friendly wallet solutions becomes increasingly important.

In this comprehensive tutorial, you'll learn how to build a wallet application that seamlessly integrates with WalletConnect using the @walletconnect/web3wallet SDK on React with Vite.

Throughout the tutorial, you'll gain insights into the integration of the Web3Wallet library with React, enabling you to build a wallet that allows users to interact with applications.

By the end of this tutorial, you'll have a solid understanding of how to build a wallet application using Web3Wallet. This tutorial will equip you with the knowledge and skills necessary to provide your users with a secure and user-friendly wallet experience.

Prerequisites

Before we get started we need to bootstrap a new React project with your favorite package manager.

I’m using Bun but feel free to use any other package manager such as npm, pnpm, yarn, etc.

bun create vite w3w-web --template react-ts
cd w3w-web

Once done, we need to setup an env file.

touch .env

Getting a Project ID

Head over to https://cloud.walletconnect.com and create an account. If you already have one, sign in instead.

Once you’ve signed in, click on the Create button.

Next, give your project a 'Name' and select 'Type' as 'Wallet'. You can change this later.

Clicking 'Create' should take you to your project dashboard.

Copy your Project ID and go to your .env file.

Create a new field called VITE_PROJECT_ID in your .env file and set the value as your Project ID.

This is what your .env file should look like after this step.

VITE_PROJECT_ID=f000000000000000000000000000000d # Your Project ID here

Installing Dependencies

To use Web3Wallet, we need to setup a few dependencies first.

bun i @walletconnect/web3wallet @walletconnect/types viem
  1. @walletconnect/web3wallet This is the core library that provides the Web3Wallet functionality, enabling seamless integration with WalletConnect. It helps abstract away the complexities of the WalletConnect protocol.
  2. @walletconnect/types This package contains the TypeScript type definitions for the WalletConnect protocol since we’re using React + Typescript for this project.
  3. viem Viem is a lightweight and modular library for interacting with Ethereum-based blockchains and wallets made by wevm. In the context of this tutorial, we'll be using Viem to generate private keys, manage adresses, and more.

Great! Now let’s move on to getting Web3Wallet set up.

Setting up Web3Wallet

For starters, let’s create a function inside src/App.tsx called init where we initialize Web3Wallet. Keep in mind we are doing this inside our component.

import {
  Web3Wallet,
  type Web3WalletTypes,
  type IWeb3Wallet,
} from "@walletconnect/web3wallet";

function App() {
	// ...
	const [web3wallet, setWeb3Wallet] = useState<IWeb3Wallet>();
	
	const init = async () => {
	
	  const core = new Core({
	    projectId: import.meta.env.VITE_PROJECT_ID,
	  });
	  
	  const w3w = await Web3Wallet.init({
	    core,
	    metadata: {
	      name: "W3W Demo",
	      description: "Demo Client as Wallet/Peer",
	      url: "www.walletconnect.com",
	      icons: [],
	    },
	  });
	  
	  setWeb3Wallet(w3w);
	};
	
	return (<div>Hello World!</div>)
}

Let’s break down what happened here.

import { Core } from "@walletconnect/core";
import {
  Web3Wallet,
  type Web3WalletTypes,
  type IWeb3Wallet,
} from "@walletconnect/web3wallet";

This line imports the required modules from the @walletconnect/web3wallet library. Specifically:

  • Web3Wallet: This is the main class that provides the functionality for initializing and managing your WalletConnect connections.
  • Web3WalletTypes: This includes TypeScript type definitions for various data structures used in the Web3Wallet library, such as session proposals and requests.
  • IWeb3Wallet: This is the interface that defines the structure and methods of the Web3Wallet instance.
function App() {
  // ...
  const [web3wallet, setWeb3Wallet] = useState<IWeb3Wallet>();
  // ...
}

Inside the App component, we use the useState hook from React to declare a state variable web3wallet and a function setWeb3Wallet to update its value.

const init = async () => {
  const core = new Core({
    projectId: import.meta.env.VITE_PROJECT_ID,
  });
  const web3WalletInstance = await Web3Wallet.init({
    core,
    metadata: {
      name: "W3W Demo",
      description: "Demo Client as Wallet/Peer",
      url: "www.walletconnect.com",
      icons: [],
    },
  });
  setWeb3Wallet(web3WalletInstance);
};

The init function is an asynchronous function responsible for initializing the Web3Wallet client.

  1. First, it creates a new instance of the Core class from the @walletconnect/core library, passing in the project ID as a configuration option. The project ID is obtained from the environment variable VITE_PROJECT_ID which we obtained a while ago.
  2. Next, it calls the Web3Wallet.init method, which initializes the Web3Wallet client. It passes in an object with two properties: core: The Core instance created in the previous step. metadata: An object containing metadata about the application, such as its name, description, URL, and icons.
  3. The init function awaits the completion of the Web3Wallet.init method and stores the resulting instance in the web3WalletInstance constant.
  4. Finally, it calls the setWeb3Wallet function, passing in the web3WalletInstance instance. This updates the web3wallet state with the initialized Web3Wallet instance.
return (<div>Hello World!</div>)

So far so good! But wait a second, we haven’t called the init method yet! Let’s fix that.

useEffect(() => {
  init();
}, []);

The useEffect hook in React is used to perform side effects in functional components. Here, the useEffect hook is used to call the init function when the component mounts.

  1. useEffect is a React hook that accepts two arguments: The first argument is a callback function that will be executed after every render cycle. The second argument is an optional array of dependencies. If this array is empty ([]), the callback function will only be executed once, on the initial render.
  2. Inside the callback function, the init function is called.

Perfect! If we load up our project now, we should have access to our Web3Wallet instance! In fact, let’s go ahead and start our project to see real time updates.

bun run dev

If everything’s working properly, this is what we should see on localhost:5173

Generating a Wallet

Before we can interact with our wallet, we need to generate one! This is where viem comes in.

import { privateKeyToAccount, generatePrivateKey } from "viem/accounts";
import {
  createWalletClient,
  http,
  type PrivateKeyAccount,
  type WalletClient,
} from "viem";
import { mainnet } from "viem/chains";

// Inside App Component

const [wallet, setWallet] = useState<WalletClient>();
const [address, setAddress] = useState<string>();
const [account, setAccount] = useState<PrivateKeyAccount>();

const chain = mainnet;

const generateAccount = () => {
    let privateKey = localStorage.getItem("WALLET_PRIVATE_KEY") as
      | `0x${string}`
      | undefined;
      
    if (!privateKey) {
      privateKey = generatePrivateKey();
      localStorage.setItem("WALLET_PRIVATE_KEY", privateKey);
    }

    const account = privateKeyToAccount(privateKey);
    setAccount(account);

    const client = createWalletClient({
      chain,
      transport: http(),
    });

    setWallet(client);

    setAddress(account.address);
  };

⚠️ Before we go any further, I want to mention that in this example I’m storing the private key in localStorage for persistence.

However, storing the private key in the browser's localStorage is NOT a secure practice. In a production environment, you should never store private keys or sensitive information in the browser. Instead, you should use secure key management solutions. This code is for educational purposes only and should not be used in a production environment without proper security measures.

Let's break down what's happening in the generateAccount function:

  1. generatePrivateKey() is a utility function provided by the viem library that generates a random private key. This private key will be used to create and manage our Ethereum account.
  2. privateKeyToAccount(privateKey) is another utility function from viem that takes the generated private key and creates an account object. This account object contains information such as the public address and the ability to sign transactions and messages.
  3. The generated account is stored in the component's state using the setAccount function.
  4. Next, we create a WalletClient instance using the createWalletClient function from viem. This client will serve as our entry point for interacting with Ethereum. We pass in two arguments: chain: This is the Ethereum chain we want to connect to. In this case, we're using the mainnet chain from viem/chains. transport: This specifies the transport layer for sending transactions and interacting with the blockchain. We're using the http() transport, which sends requests over HTTP.
  5. The created WalletClient instance is stored in the component's state using the setWallet function.
  6. Finally, we store the generated account's public address in the component's state using the setAddress function.

By calling the generateAccount function, we've created a new Ethereum account with a randomly generated private key, and we've set up a WalletClient instance for interacting with the Ethereum mainnet. The generated account's address is also stored in the component's state and can be displayed in the user interface.

Let’s call this function along with init when the page loads up.

useEffect(() => {
  generateAccount();
  init();
}, []);

Let’s show this address in our UI.

return (<p>Generated Address: {address}</p>)

We just made our very first Ethereum account dynamically through a website! How cool is that?

Great going! It’s time to start connecting to dapps! We will use https://react-app.walletconnect.com for testing.

Let’s create a function to pair with a given URI

const [isConnected, setIsConnected] = useState<boolean>(false);
const [uri, setUri] = useState<string>();

// ...

const pair = async () => {
    if (uri) {
      try {
        console.log("pairing with uri", uri);
        await web3wallet?.pair({ uri });
        setIsConnected(true);
      } catch (e) {
        console.error("Error pairing with uri", e);
      }
    }
  };

This function is responsible for initiating the pairing process between the Web3Wallet instance and a compatible app using a WalletConnect URI.

  1. await web3wallet?.pair({ uri }) is the key line that initiates the pairing process. Here's what's happening: web3wallet is the instance of Web3Wallet that was initialized earlier. await pair({ uri }) calls the pair method on the web3wallet instance, passing an object with the uri property. This uri is the WalletConnect URI that’s used to establish the connection.
  2. If the pairing is successful, setIsConnected(true) is called to update the isConnected state variable to true, indicating that the pairing was successful and a connection has been established.
  3. If there's an error during the pairing process, the catch block is executed, and console.error("Error pairing with uri", e) logs an error message to the console, along with the error object e.

Let’s add an input and button to interact with this function.

return (
  <>
    <p>Generated Address: {address}</p>
    <div className="form-container">
      <input
        type="text"
        onChange={(e) => setUri(e.target.value)}
        placeholder="Enter URI"
        className="uri-input"
      />
      <button type="button" onClick={pair}>
        Pair
      </button>
    </div>
  </>
);
const [session, setSession] = useState<SessionTypes.Struct>();

// ...

useEffect(() => {
  if (web3wallet) {
    web3wallet.on("session_proposal", onSessionProposal);
    web3wallet.on("session_request", onSessionRequest);

    const activeSessions = web3wallet?.getActiveSessions();

    if (activeSessions) {
      const currentSession = Object.values(activeSessions)[0];
      setSession(currentSession);
      setIsConnected(Object.keys(activeSessions).length > 0);
    }
  }
  
  return () => {
		web3wallet?.off("session_proposal", onSessionProposal);
    web3wallet?.off("session_request", onSessionRequest);
	}
}, [onSessionProposal, onSessionRequest, web3wallet]);
  1. web3wallet.on("session_proposal", onSessionProposal) sets up an event listener for the session_proposal event using the onSessionProposal callback function. This callback will be invoked when a new session proposal is received from the connected wallet. (We will define onSessionProposal in the next step)
  2. Similarly, web3wallet.on("session_request", onSessionRequest) sets up an event listener for the session_request event using the onSessionRequest callback function. (We will define onSessionRequest in the next step as well)
  3. const activeSessions = web3wallet?.getActiveSessions(); retrieves the active sessions from the web3wallet instance using the getActiveSessions method.
  4. const currentSession = Object.values(activeSessions)[0]; retrieves the first active session from the activeSessions object.
  5. setSession(currentSession) updates the session state variable with the current active session.
  6. setIsConnected(Object.keys(activeSessions).length > 0) updates the isConnected state variable based on whether there are any active sessions or not. If there are active sessions, isConnected will be set to true; otherwise, it will be set to false.

Let’s throw in a button that disconnects the user from the session.

{isConnected && (
  <button
    type="button"
    onClick={() => {
      web3wallet?.disconnectSession({
        topic: session?.topic as string,
        reason: {
          code: 5000,
          message: "User disconnected",
        },
      });
      setIsConnected(false);
    }}
  >
    Disconnect Session
  </button>
)}

You might’ve noticed two functions onSessionProposal and onSessionRequest . Let’s define those!

import { buildApprovedNamespaces, getSdkError } from "@walletconnect/utils";

// Inside App Component

const onSessionProposal = useCallback(
  async ({ id, params }: Web3WalletTypes.SessionProposal) => {
    try {
      if (!address) {
        throw new Error("Address not available");
      }
      const namespaces = {
        proposal: params,
        supportedNamespaces: {
          eip155: {
            chains: [`eip155:${chain.id}`],
            methods: ["eth_sendTransaction", "personal_sign"],
            events: ["accountsChanged", "chainChanged"],
            accounts: [`eip155:${chain.id}:${address}`],
          },
        },
      };

      const approvedNamespaces = buildApprovedNamespaces(namespaces);

      const session = await web3wallet?.approveSession({
        id,
        namespaces: approvedNamespaces,
      });

      setSession(session);
    } catch (error) {
      await web3wallet?.rejectSession({
        id,
        reason: getSdkError("USER_REJECTED"),
      });
    }
  },
  [address, chain, web3wallet]
);
  1. The function first checks if the address state variable is available. If not, it throws an error.
  2. If the address is available, it constructs a namespaces object with the following structure: proposal: Contains the params received from the session proposal. supportedNamespaces: An object defining the namespaces supported by the application, in this case, the eip155 namespace. chains: An array containing the EIP-155 chain ID, formatted as eip155:${chain.id}. We’ve already set chain to mainnet before. methods: An array of supported JSON-RPC methods, such as eth_sendTransaction and personal_sign. events: An array of supported events, such as accountsChanged and chainChanged. accounts: An array containing the user's account address, formatted as eip155:${chain.id}:${address}.
  3. The namespaces object is logged to the console to make debugging easier for us.
  4. The buildApprovedNamespaces function (imported from @walletconnect/utils) is called with the namespaces object to construct the approvedNamespaces object.
  5. The approveSession method is called on the web3wallet instance with the id and approvedNamespaces as arguments. This method approves the session proposal with the specified namespaces.
  6. If the approveSession call is successful, the resulting session object is stored in the component's state using the setSession function.
  7. If an error occurs during the session approval process (e.g., the user rejects the session proposal), the catch block is executed.
  8. In the catch block, the rejectSession method is called on the web3wallet instance (if it exists) with the id and a reason object containing the error message "USER_REJECTED". This rejects the session proposal with the specified reason.

The useCallback hook ensures that the onSessionProposal function is only re-created if the address, chain, or web3wallet values change, improving performance by avoiding unnecessary re-renders.

Next stop, onSessionRequest

import { hexToString } from "viem";

// ...

const [requestContent, setRequestContent] = useState({
    method: "",
    message: "",
    topic: "",
    response: {},
  });
const dialogRef = useRef<HTMLDialogElement>(null);

// ...

const onSessionRequest = useCallback(
  async (event: Web3WalletTypes.SessionRequest) => {
    const { topic, params, id } = event;
    const { request } = params;
    const requestParamsMessage = request.params[0];

    const message = hexToString(requestParamsMessage);

    const signedMessage = await wallet?.signMessage({
      account: account as PrivateKeyAccount,
      message,
    });

    setRequestContent({
      message,
      method: request.method,
      topic,
      response: {
        id,
        jsonrpc: "2.0",
        result: signedMessage,
      },
    });

    dialogRef.current?.showModal();
  },
  [account, wallet]
);
  1. const requestParamsMessage = request.params[0]; retrieves the first parameter from the request.params array. This parameter is expected to be a hexadecimal string representation of the message to be signed.
  2. const message = hexToString(requestParamsMessage); converts the hexadecimal string to a regular string using the hexToString utility function imported from viem.
  3. const signedMessage = await wallet?.signMessage({ ... }) signs the message using the signMessage method of the wallet instance. It passes an object with two properties: account: The account state variable, cast as a PrivateKeyAccount type. message: The string representation of the message obtained from the previous step.
  4. setRequestContent({ ... }) updates the requestContent state with a new object containing the following properties: message: The string representation of the message. method: The method of the current request, obtained from request.method. topic: The topic of the session request, obtained from the topic variable. response: An object with properties id (from the id variable), jsonrpc (set to "2.0"), and result (the signed message).

Hang on, what’s dialogRef ? Getting there now!

To allow users to see what message they’re signing, we need to create a dialog to display the message and two buttons, one to accept, and the other to reject.

<dialog ref={dialogRef}>
  <h3>
    New approval for <span>{requestContent.method}</span>
  </h3>
  <code>{requestContent.message}</code>
  <div className="btn-container">
    <button type="button" onClick={onAcceptSessionRequest}>
      Accept
    </button>
    <button type="button" onClick={onRejectSessionRequest}>
      Reject
    </button>
  </div>
</dialog>

Cool! Finally, let’s define onAcceptSessionRequest and onRejectSessionRequest

const onAcceptSessionRequest = async () => {
  const { topic, response } = requestContent;
  await web3wallet?.respondSessionRequest({
    topic,
    response: response as {
      id: number;
      jsonrpc: string;
      result: `0x${string}`;
    },
  });
  dialogRef.current?.close();
};
  1. It calls the respondSessionRequest method on the web3wallet instance.
  2. The respondSessionRequest method is passed an object with two properties: topic: The topic of the session request, obtained from the requestContent state. response: The response object from the requestContent state, cast as an object with properties id (number), jsonrpc (string), and result (a hexadecimal string prefixed with 0x).
  3. After responding to the session request, it calls the close method on the <dialog> element referenced by dialogRef.current (if it exists). This will close the dialog box.
const onRejectSessionRequest = async () => {
  const { topic, response } = requestContent;
  const { id } = response as { id: number };
  await web3wallet?.respondSessionRequest({
    topic,
    response: {
      id,
      jsonrpc: "2.0",
      error: {
        code: 5000,
        message: "User rejected.",
      },
    },
  });
  dialogRef.current?.close();
};
  1. It calls the respondSessionRequest method on the web3wallet instance (if it exists).
  2. The respondSessionRequest method is passed an object with two properties: topic: The topic of the session request, obtained from the requestContent state. response: An object with the following properties: id: The id obtained from the response object in the requestContent state. jsonrpc: Set to "2.0". error: An object representing the error response, with a code of 5000 and a message of "User rejected.".
  3. After responding to the session request, it calls the close method on the <dialog> element referenced by dialogRef.current (if it exists). This will close the dialog box.

That was quite a bit of legwork, but we are finally all done! This is hopefully what your UI looks like right now.

Time to put it to the test. Ideally, this is what the flow should look like.

Resources

Ending Notes

You have now learned how to build a basic wallet that can connect to apps. Take a moment to acknowledge your accomplishment - well done!

You understand how to generate wallets, handle connection requests, sign messages, and get user approval. This tutorial should provide you with a solid foundation for creating your own wallets!

As a challenge, try implementing additional functionalities such as enabling transaction sending or exploring methods for enhancing the security of generating and storing private keys.

The future is exciting, and your new skills allow you to be part of making a more open and user-friendly internet. Keep exploring and building - the possibilities are endless!

Recommended Articles
More articles
alt=""

Build what's next.
Build with WalletConnect.

Get started
© 2024 WalletConnect, Inc.