This template should help get you started developing with Web3, Vue3, and Vite.
The logic for connecting to web3 is in the src/utils
folder. The function is reproduced here:
export const connectWeb3 = async (): Promise<ConnectResult> => {
const ethProvider = await detectEthereumProvider();
const provider = new providers.Web3Provider(ethProvider!);
if (provider) {
try {
const accounts: string[] = await getEthereum().request({
method: "eth_requestAccounts",
});
await checkNetwork();
return { accounts, provider };
} catch (err) {
console.log(err);
return { accounts: null, provider: null };
}
} else {
return { accounts: null, provider: null };
}
};
Function explanation
- First, it checks whether web3 is enabled in the clients browser with detectEthereumProvider
- if there is a provider, we use a metamask method
eth_requestAccounts
to prompt the user to connect our Dapp to metamask and retrieve the list of accounts a user has. - if successful, we return the list of accounts and provider (after checking that the network is the one we specify with
checkNetwork
method.)
We the user to use a specific network for a project, such as Ethereum mainnet. We set this network in the constants/index
folder, like so:
export const ChainID = import.meta.env.VITE_NETWORK_ID || "1";
By default, we use ethereum mainnet. If we were to target a different network, say for testing purposes, we would need to create a .env
file with the following data
VITE_NETWORK_ID = <target network>
After this is done, we can look at the checkNetwork
function:
const checkNetwork = async () => {
if (getEthereum()) {
const hex = "0x" + parseInt(ChainID).toString(16);
try {
// check if the chain to connect to is installed
await getEthereum().request({
method: "wallet_switchEthereumChain",
params: [{ chainId: hex }], // chainId must be in hexadecimal numbers
});
} catch (error) {
// This error code indicates that
/// the chain has not been added to MetaMask
// if it is not, then install it into the user MetaMask
// @ts-ignore
if (error.code === 4902) {
try {
await getEthereum().request({
method: "wallet_addEthereumChain",
params: [
{
chainId: hex,
rpcUrl: "https://data-seed-prebsc-1-s1.binance.org:8545/",
},
],
});
} catch (addError) {
console.log(addError);
}
}
}
}
};
Function explanation
- first, we convert the
ChainID
variable to a hexadecimal version, as this is what metamask expects. - Then, the metamask method to request a switch in network. If the user is currently on the correct chain, nothing happens. Otherwise a popup will appear for a chain switch.
- If the chain has not been added by a user, (error 4902), a popup will appear to add the target chain.
The connectWeb3
function above is called from a vue component Connect.vue
found in src/components
. It is copied below:
<script setup lang="ts">
import { store } from "@/store";
const connect = async () => {
await store.connect();
};
</script>
<template>
<div id="connect-wrap">
<button v-if="!store.isConnected" @click="connect">Connect</button>
<button v-if="store.isConnected">
{{ store.activeAccount }}
</button>
</div>
</template>
You can see that the function connectWeb3
in the src/utils
folder we have previously discussed is not called directly. Instead, it passes through our state management system. The system is intutive, and is found in src/store/index
. It's reproduced here:
import { markRaw, reactive } from "vue";
import type { StoreInterface } from "@/models";
import { connectWeb3 } from "@/utils";
import { contractStore } from "./contracts";
export const store: StoreInterface = reactive({
isConnected: false,
activeAccount: "",
provider: null,
signer: null,
connect: async () => {
if (store.isConnected) return;
const { accounts, provider } = await connectWeb3();
if (accounts && provider) {
store.isConnected = true;
store.activeAccount = accounts[0];
store.provider = markRaw(provider);
store.signer = markRaw(provider.getSigner());
contractStore.createContractInstance(store.signer);
}
},
});
Here, you can see the connect
function that is called when a user clicks the connect button from the Connect.vue
component we defined above. This connect function first checks if we are already connected, and returns if so. If we are not, then the connectWeb3
function is called from the utils
folder. We then check if the accounts
and provider
are valid, and then populate state based on that.
populating the state is project dependant, but for this example, we do the following things:
- set
isConnected
totrue
, so that this function will not run this logic again in the same session. - set the
activeAccount
to the first account returned from metamask - set the
provider
field to a raw provider returned from metamask - set the
signer
field to the user via theprovider.getSigner()
function. This is then used to instantiate contracts.
In order to instantiate a contract, we need three pieces of information:
- The address
- The ABI
- The signer or provider
We define the metadata of contracts relevant to our project in the src/constants
folder like so:
export const CONTRACTS = [
{
network: 1,
vrt: "0xBE682C3E3beB8e82623D30e9608Ca2313e47bA4D",
},
];
You'll also find the abi
folder in src/constants
, where the VRT token (as an example) is defined. Having all of these pieces of information, we can then inspect the createContractInstance
function in the src/utils
folder:
export const createContractInstance = (
contractName: string,
abi: any,
signer: providers.Web3Provider
) => {
const config = CONTRACTS.filter((c) => c.network == Number(ChainID));
// @ts-ignore
return markRaw(new ethers.Contract(config[0][contractName], abi, signer));
};
function explanation
- We get the right network config based on the target chain
ChainID
- The new contract instance is then returned using the ethers contract method.
We can then circle back to our state management system, and fully define the last line in the connect
function:
contractStore.createContractInstance(store.signer)
Here what we are doing is calling a seperate store, namely the contractStore
found in src/store/contracts
, defined below:
import { markRaw, reactive } from "vue";
import type { ContractStoreInterface } from "@/models";
import type { providers } from "ethers";
import { createContractInstance } from "@/utils";
import { vrtABI } from "@/constants/abi/vrt";
export const contractStore: ContractStoreInterface = reactive({
vrt: null,
createContractInstance: async (signer: providers.Web3Provider) => {
contractStore.vrt = markRaw(createContractInstance("vrt", vrtABI, signer));
},
});
This store will house all of our contracts, and showcases that we do not need a monolithic state management system, we can separate app logic into different stores!
The createContractInstance
function calls the function by the same name, that we've defined above and in the utils
folder, and assign that contract to the vrt
field in the contract store. From here, contract functions can be called as regular, with the signer attached.
If our use case is simply to read the state of the blockchain, the first RPC we should attempt to use is an injected provider. We can check to see if the web browser supports web3 with the following function:
// if window.ethereum is undefined, evm based web3 is not supported in the current browser
const getEthereum = () => {
// @ts-ignore
return window.ethereum;
};
If web3 is supported, then we can create a Web3Provider
instance with it, like so:
export const connectInjectedProvider = () => {
const provider = getEthereum();
if (provider) {
return new ethers.providers.Web3Provider(provider);
}
};
This provider can then be used to instantiate contracts to query read only functions. This should be done as early as possible in the Apps lifecycle, for best UX results. In our scaffolding, this was implemented for the vrt
contract in App.vue
like so:
import { RouterView } from "vue-router";
import { onMounted } from "vue";
import { connectInjectedProvider } from "./utils";
import { contractStore } from "./store/contracts";
onMounted(() => {
const provider = connectProvider();
if (provider) contractStore.createContractInstance(provider);
// else connect to infura
});
If there is no provider, then the dapp should fall back to an Infura connection or a similar service. This can also happen in the mounted hook above, but ideally it should happen in the connectProvider
function, such that it always returns a provider in the mounted hook.