Unleash the Power of Injected Wallet Choice: A Comprehensive Guide to EIP-6963 for dApp Developers
Introduction
In today’s rapidly evolving web3 landscape, user experience is a top priority. One of the biggest pain points for dApp users (and developers of course) is limited choice when connecting to injected wallets. EIP-6963, a revolutionary Ethereum Improvement Proposal, aims to change the game by empowering users to connect their preferred wallet extensions to any dApp seamlessly.
This guide delves deep into the benefits of implementing EIP-6963, unpack the technical details, and walks you through a step-by-step implementation process in a react application.
By the end of this guide, you’ll have a very good understanding of EIP-6963, and how to utilize it for a future-proof and user-centric experience.
Pre EIP-6963
Before EIP-6963, we had EIP-1193, which defined a standard for the Ethereum provider API. This API allows dApps to communicate with the blockchain and user accounts through wallets. However, while EIP-1193 addressed how the provider API functioned, it did not specify where wallet extensions should make the provider available for dApp usage.
By convention, not a standard, wallet extensions historically injected their provider into the window.ethereum
object. This practice, introduced by the Mist wallet back in 2015, became widely adopted. However, this approach presented several challenges, some of which are:
- Single Provider Limitation:
window.ethereum
could only accommodate a single provider, leading to conflicts if multiple wallets were installed. When a wallet injected its provider, it would overwrite the one already present, essentially causing "wallet wars" where each extension tried to inject last. - Undeterministic Wallet Connection: Another challenge was that with multiple wallets installed, connecting through
window.ethereum
became unpredictable. Neither the developer nor the user could control or know which wallet would respond to the connection request. This resulted in a confusing and unreliable experience. - High Barrier to entry for new injected wallets: The dependency on
window.ethereum
created a competitive landscape for new wallets. If a new wallet wanted to be used on a dApp, it needed to fight for the final injection into window.ethereum, a risky move with potential for it’s functionality to be immediately overwritten by another extension. This discouraged innovation and limited competition within the wallet ecosystem.
One of the notable attempt to solve this “wallet wars” problem was the EIP-5749, but it was not widely adopted by wallets to solve the problem.
Post EIP-6963
EIP-6963 effectively addresses these issues by introducing a new communication channel between dApps and wallets. This eliminates the single provider bottleneck, allowing users to choose their preferred wallet seamlessly. It also enables wallets to share richer information with dApps, enhancing the user experience and promoting a more secure and standardized approach for interacting with blockchain applications.
How EIP-6963 Solved the Problem
EIP-6963 elegantly tackles the limitations of the window.ethereum
approach by introducing a new discovery and communication mechanism.
Instead of directly injecting into window.ethereum
, it enables dApps to interact with installed wallets through a standardized interface, providing reliable two-way communication.
Here’s a breakdown of how it works:
- The dApp requests providers: The dApp broadcasts the
eip6963:requestProvider
event, essentially informing all installed wallets that a user wants to connect. - Wallet extensions respond to the request: Upon receiving this event, each wallet extension responds by broadcasting an
eip6963:announceProvider
event with its provider and details as a payload. These details include the wallet’s name, logo, unique identifiers, and Reverse Domain Name Service (RDNS). This richer data enables dApps to display clear wallet options to the user and tailor the experience accordingly. - The dApp builds a list of available wallets from the responses: The dApp receives information from responding wallets and builds a list of available wallet options for the user to connect with.
EIP-6963 puts the user firmly in control by allowing dApps to present a list of available wallets. The user can then consciously select their preferred wallet, promoting greater choice and trust.
This standard doesn’t enforce a rigid implementation. Developers can choose how to display and incorporate EIP-6963’s enhanced wallet communication into their dApps, allowing for tailored and user-friendly interfaces.
At the time of writing, many wallets have already implemented EIP-6963, while others are actively working towards integration.
Talk is Cheap, show me the code
Enough of the theory. Now, we’ll try to create a simple dApp implenting EIP-6963.
To save time and also ensure that we start on the same page, I’ve prepared a starter kit, with all dependencies setup, so you can quickly get up to speed.
Clone the starter kit repository
To install the dependencies, run yarn install
and then start the development server by running yarn dev
The development server should be started at http://localhost:5173
If you open it on your browser, you should be greeted with a black “Hello world!” text on a white background.
Now, let’s get straight to business.
Lets start by defining all our types.
Open the src/vite-env.d.ts
file and paste these types in there
/// <reference types="vite/client" />
/**
* Represents the assets needed to display and identify a wallet.
*
* @type EIP6963ProviderInfo
* @property uuid - A locally unique identifier for the wallet. MUST be a v4 UUID.
* @property name - The name of the wallet.
* @property icon - The icon for the wallet. MUST be data URI.
* @property rdns - The reverse syntax domain name identifier for the wallet.
*/
type EIP6963ProviderInfo = {
uuid: string;
name: string;
icon: string;
rdns: string;
};
/**
* @type EIP1193Provider
* @dev a minimal interface of EIP1193 Provider
*/
type EIP1193Provider = {
request: (payload: {
method: string;
params?: unknown[] | object;
}) => Promise<unknown>;
};
/**
* Represents a provider and the information relevant for the dapp.
*
* @type EIP6963ProviderDetail
* @property info - The EIP6963ProviderInfo object.
* @property provider - The provider instance.
*/
type EIP6963ProviderDetail = {
info: EIP6963ProviderInfo;
provider: EIP1193Provider;
};
type EIP6963AnnounceProviderEvent = Event & {
detail: EIP6963ProviderDetail;
};
These types are already well explained by the comments
EIP6963ProviderInfo
— the type of the info object every extension MUST send to dApps alongside its provider
EIP1193Provider
— minimal version of EIP1193 provider interface
EIP6963ProviderDetail
— the type of payload the extension sends when they announce themselves to dApps
EIP6963AnnounceProviderEvent
— the type of event emitted by the extensions.
If all the types above seems confusing, follow along, you’ll understand better when you get to where they’re being used.
Next, let’s define our configs.
create src/config/index.ts
Inside of the file, paste the following
/**
* @title EIP6963EventNames
* @dev Enum defining EIP-6963 event names.
*/
export enum EIP6963EventNames {
Announce = "eip6963:announceProvider",
Request = "eip6963:requestProvider",
}
/**
* @title SupportedChainId
* @dev Enum defining supported chain IDs.
*/
export enum SupportedChainId {
SEPOLIA = 11155111,
NAHMII3_TESTNET = 4062,
}
/**
* @title LOCAL_STORAGE_KEYS
* @dev Object containing local storage keys used in the dApp PREVIOUSLY_CONNECTED_PROVIDER_RDNS is the key under which the rdns of the previously connected provider is stored.
* @
*/
export const LOCAL_STORAGE_KEYS = {
PREVIOUSLY_CONNECTED_PROVIDER_RDNS: "PREVIOUSLY_CONNECTED_PROVIDER_RDNS",
};
/**
* @title networkInfoMap
* @dev Object containing network information for supported chains.
*/
export const networkInfoMap = {
[SupportedChainId.SEPOLIA]: {
chainId: `0x${SupportedChainId.SEPOLIA.toString(16)}`,
chainName: "Sepolia test network",
rpcUrls: ["https://sepolia.infura.io/v3/"],
blockExplorerUrls: ["https://sepolia.etherscan.io"],
nativeCurrency: {
name: "ETH",
symbol: "ETH",
decimals: 18,
},
},
[SupportedChainId.NAHMII3_TESTNET]: {
chainId: `0x${SupportedChainId.NAHMII3_TESTNET.toString(16)}`,
chainName: "Nahmii3 Test Network",
rpcUrls: ["https://rpc.testnet.nahmii.io/"],
blockExplorerUrls: ["https://explorer.testnet.nahmii.io/"],
nativeCurrency: {
name: "ETH",
symbol: "ETH",
decimals: 18,
},
},
};
/**
* @title isPreviouslyConnectedProvider
* @dev Function to check if a provider was previously connected by comparing its rdns to the rdns previously store in the local storage the last time a connection was made.
* @param providerRDNS The provider RDNS string.
* @returns True if the providerRDNS matches the rdns found in the local storage.
*/
export function isPreviouslyConnectedProvider(providerRDNS: string): boolean {
return (
localStorage.getItem(
LOCAL_STORAGE_KEYS.PREVIOUSLY_CONNECTED_PROVIDER_RDNS
) === providerRDNS
);
}
/**
* @title isSupportedChain
* @dev Function to check if a chain is supported.
* @param chainId The chain ID to check.
* @returns True if the chain ID is supported, false otherwise.
*/
export function isSupportedChain(
chainId: number | null | undefined
): chainId is SupportedChainId {
if (!chainId) return false;
return !!SupportedChainId[chainId];
}
/**
* @title switchChain
* @dev Function to switch to a supported chain.
* @param chain The chain ID to switch to.
* @param provider The EIP1193Provider instance.
*/
export const switchChain = async (chain: number, provider: EIP1193Provider) => {
if (!isSupportedChain(chain))
return console.error("attempt to switch to a wrong chain!");
try {
await provider.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: `0x${chain.toString(16)}` }],
});
} catch (error: any) {
if (error.code === 4902 || error.code === -32603) {
const chainInfo = networkInfoMap[chain];
try {
await provider.request({
method: "wallet_addEthereumChain",
params: [chainInfo],
});
} catch (addError) {
console.error("user rejected network addition!");
}
}
}
};
These are constants and utils used throughout this project. Again, they’re also well explained by the comments and can be better understood during usage.
Next, we will define our only component, Which is the Wallet button
create src/components/WalletButtons.tsx
and paste this in
import { Button } from "@radix-ui/themes";
import { DotFilledIcon } from "@radix-ui/react-icons";
const WalletButton: React.FC<{
handleConnect: (walletDetails: EIP6963ProviderDetail) => void;
walletDetails: EIP6963ProviderDetail;
isConneted?: boolean;
}> = ({ walletDetails, handleConnect, isConneted }) => {
return (
<Button
onClick={() => handleConnect(walletDetails)}
className="flex"
disabled={isConneted}
>
<img
className="w-5 h-5 rounded"
src={walletDetails.info.icon}
alt={walletDetails.info.name}
/>
<span>{walletDetails.info.name}</span>
{isConneted && (
<DotFilledIcon color="green" width={24} height={24} />
)}
</Button>
);
};
export default WalletButton;
This is the button that will be rendered for each of the providers we get from our installed wallet extensions.
Next, we will work in the App.tsx.
To make this straight forward, we will paste in all the code and explain later.
Open the App.tsx
file and override its content with the code below
import {Badge, Box, Button, Container, Flex, Heading, Section} from "@radix-ui/themes";
import { useEffect, useState } from "react";
import {EIP6963EventNames, LOCAL_STORAGE_KEYS, SupportedChainId, isPreviouslyConnectedProvider, isSupportedChain, switchChain,} from "./config";
import {CodeSandboxLogoIcon, DrawingPinIcon, RocketIcon} from "@radix-ui/react-icons";
import WalletButton from "./components/WalletButtons";
function App() {
/**
* @title injectedProviders
* @dev State variable to store injected providers we have recieved from the extension as a map.
*/
const [injectedProviders, setInjectedProviders] = useState<
Map<string, EIP6963ProviderDetail>
>(new Map());
/**
* @title connection
* @dev State variable to store connection information.
*/
const [connection, setConnection] = useState<{
providerUUID: string;
accounts: string[];
chainId: number;
} | null>(null);
useEffect(() => {
/**
* @title onAnnounceProvider
* @dev Event listener for EIP-6963 announce provider event.
* @param event The announce provider event.
*/
const onAnnounceProvider = (event: EIP6963AnnounceProviderEvent) => {
const { icon, rdns, uuid, name } = event.detail.info;
if (!icon || !rdns || !uuid || !name) {
console.error("invalid eip6963 provider info received!");
return;
}
setInjectedProviders((prevProviders) => {
const providers = new Map(prevProviders);
providers.set(uuid, event.detail);
return providers;
});
// This ensures that on page reload, the provider that was previously connected is automatically connected again.
// It help prevent the need to manually reconnect again when the page reloads
if (isPreviouslyConnectedProvider(rdns)) {
handleConnectProvider(event.detail);
}
};
// Add event listener for EIP-6963 announce provider event
window.addEventListener(
EIP6963EventNames.Announce,
onAnnounceProvider as EventListener
);
// Dispatch the request for EIP-6963 provider
window.dispatchEvent(new Event(EIP6963EventNames.Request));
// Clean up by removing the event listener and resetting injected providers
return () => {
window.removeEventListener(
EIP6963EventNames.Announce,
onAnnounceProvider as EventListener
);
setInjectedProviders(new Map());
};
}, []);
/**
* @title handleConnectProvider
* @dev Function to handle connecting to a provider.
* @param selectedProviderDetails The selected provider details.
*/
async function handleConnectProvider(
selectedProviderDetails: EIP6963ProviderDetail
) {
const { provider, info } = selectedProviderDetails;
try {
const accounts = (await provider.request({
method: "eth_requestAccounts",
})) as string[];
const chainId = await provider.request({ method: "eth_chainId" });
setConnection({
providerUUID: info.uuid,
accounts,
chainId: Number(chainId),
});
localStorage.setItem(
LOCAL_STORAGE_KEYS.PREVIOUSLY_CONNECTED_PROVIDER_RDNS,
info.rdns
);
} catch (error) {
console.error(error);
throw new Error("Failed to connect to provider");
}
}
/**
* @title handleSwitchChain
* @dev Function to handle switching the chain.
*/
const handleSwitchChain = async () => {
try {
if (!connection) return;
const provider = injectedProviders.get(
connection.providerUUID
)!.provider;
const chain = isSupportedChain(connection.chainId)
? connection.chainId === SupportedChainId.SEPOLIA
? SupportedChainId.NAHMII3_TESTNET
: SupportedChainId.SEPOLIA
: SupportedChainId.SEPOLIA;
await switchChain(chain, provider);
setConnection({
...connection,
chainId: chain,
});
} catch (error) {
console.error(error);
}
};
/**
* @title handleDisconnect
* @dev Function to handle disconnecting from the provider.
*/
const handleDisconnect = () => {
setConnection(null);
localStorage.removeItem(
LOCAL_STORAGE_KEYS.PREVIOUSLY_CONNECTED_PROVIDER_RDNS
);
};
const connectedInjectectProvider =
connection && injectedProviders.get(connection.providerUUID);
return (
<Box>
<Section py="4" className="border-b p-4">
<Container>
<Flex align={"center"} className="gap-2">
<CodeSandboxLogoIcon width={36} height={36} />
<Heading as="h1">EIP6963 Playground</Heading>
</Flex>
</Container>
</Section>
<Container>
<Flex py="4" className="flex-col md:flex-row gap-4 p-4 md:p-0">
<Box className="w-full md:w-1/2">
<Flex align={"center"} className="gap-2 mb-4">
<Heading as="h2">
Available Injected wallets
</Heading>
<RocketIcon width={24} height={24} />
</Flex>
{injectedProviders.size === 0 ? (
<div>
You do not have any wallet extension installed
on your browser
</div>
) : (
<Flex className="gap-2 mb-4 flex-wrap">
{Array.from(injectedProviders).map(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
([_, { info, provider }]) => (
<WalletButton
key={info.uuid}
handleConnect={
handleConnectProvider
}
walletDetails={{ info, provider }}
isConneted={
connection?.providerUUID ===
info.uuid
}
/>
)
)}
</Flex>
)}
</Box>
<Box className="w-full md:w-1/2">
<Flex align={"center"} className="gap-2">
<Heading as="h2">Connection Details</Heading>
<DrawingPinIcon width={24} height={24} />
</Flex>
<Box className="w-full">
{connectedInjectectProvider?.info ? (
<Flex className="flex-col gap-2">
<Flex className="gap-2">
<span>Connected to:</span>
{
<Flex gap="1" align="center">
<span>
{}
{
connectedInjectectProvider
.info.name
}
</span>{" "}
<img
className="w-5 h-5 rounded"
src={
connectedInjectectProvider
.info.icon
}
alt={
connectedInjectectProvider
.info.name
}
/>
</Flex>
}
</Flex>
<Flex className="gap-2">
<span>Chain ID:</span>
<Flex gap="1" align="center">
<span>{connection?.chainId}</span>
{isSupportedChain(
connection?.chainId
) ? (
<Badge color="green">
Supported
</Badge>
) : (
<Badge color="orange">
Unsupported
</Badge>
)}
</Flex>
</Flex>
<Flex className="gap-2">
<span>Accounts:</span>
<span>
{connection?.accounts.map(
(account) => (
<span key={account}>
{account}
</span>
)
)}
</span>
</Flex>
<Flex>
<Button
onClick={handleSwitchChain}
className="cursor-pointer"
>
Switch Chain
</Button>
</Flex>
<Flex>
<Button onClick={handleDisconnect}>
Disconnect
</Button>
</Flex>
<Flex></Flex>
</Flex>
) : (
<Box>Not connected</Box>
)}
</Box>
</Box>
</Flex>
</Container>
</Box>
);
}
export default App;
Let’s see what’s going on in the code above
- We started by defining a state variable
injectedProviders
for holding the providers gotten from all the extensions. - Defined a
connection
state variable that holds the details of the current connection. the details includes the uuid of the provider we’re currently connected to, the connected accounts, and the chain id. - Inside the useEffect, we defined
onAnnounceProvider
handler. This is the callback function that gets called whenever any wallet extension announce itself. This function does three things:
1. it ensures that alongside sending the provider to the dApp, the extension also sends all the info (icon, rdns, uuid, name) specified in the EIP-6963.
2. It adds the provider to the list of providers in the injectedProviders state.
3. if this provider is the one that was previously connected, it automatically reconnect silently. - After the
onAnnounceProvider
definition, we listen for theeip6963:announceProvider
event withonAnnounceProvider
handler as the listener. - Then we dispatched the
eip6963:requestProvider
event which notifies the wallet extensions to send in their providers. - Returned a cleanup function to remove the event listener. And this marked the end of the useEffect.
- Next, we defined
handleConnectProvider
,handleSwitchChain
, andhandleDisconnect
for connecting to a provider, switching to another chain, and disconnecting from a provider respectively. - Finally we render our jsx.
If the code above seems confussing, I suggest you take a look at it one block at a time.
One last thing before we start the application is to update the main.tsx file, and override its content with this
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { Theme } from "@radix-ui/themes";
import "@radix-ui/themes/styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Theme>
<App />
</Theme>
</React.StrictMode>
);
Now, if you’ve stopped running the development server, start the application using yarn dev
Once started and you go to http://localhost:5173
If you’ve done everything correctly, and you have some wallet extension installed, you should see something like this
When you connect to any of the provider, the connection details should show up in the “Connection Details” section like so.
And that’s a wrap.
Don’t forget to drop a clap and share if you like it. Also do well to drop a comment if you have any question or would like to discuss this further.
You can find the final code at: https://github.com/AjayiMike/eip-6963-playground
And the deployed version at: https://ajayimike.github.io/eip-6963-playground
Resouces
https://eips.ethereum.org/EIPS/eip-6963
https://www.youtube.com/watch?v=SWmknCUwr3Y
https://eips.ethereum.org/EIPS/eip-5749
https://eips.ethereum.org/EIPS/eip-1193