Building a Frontend
Introduction
In this tutorial, we'll demonstrate how to set up a frontend project using React. You'll learn how to connect to a Sei wallet and interact with EVM and CosmWasm smart contracts deployed on Sei.
Select one of the tabs below to get started!
In this section, we'll explore Sei's unique interoperability features by building an EVM compatible DApp that interacts with a CosmWasm smart contract. We will use ethers.js (opens in a new tab) to build a React app that interacts with a CosmWasm smart contract using the Sei CosmWasm precompile (opens in a new tab).
Prerequisites
- Complete the tutorial in cosmwasm-general to deploy a CosmWasm counter contract on our devnet (arctic-1).
Requirements
Before starting, ensure you have:
- Node.js & NPM installed
- One of the Sei wallets listed here
Creating a React Project
Start by creating a new React project using Vite's TypeScript template for streamlined development:
npm create vite@latest my-counter-frontend -- --template react-ts
This command creates a new folder with a React project using TypeScript. Open
my-counter-frontend
in your favorite IDE.
Installing Dependencies
Install ethers
, an Ethereum library that facilitates interaction with the
Ethereum blockchain:
npm install ethers
Defining Contract Addresses and ABI
In this tutorial, we will be using the Wasm Precompile to interact with our CosmWasm contract from the EVM. Precompiles (short for Precompiled contracts) are EVM compatible contracts that are built into the chain. The Wasm Precompile is a unique smart contract on Sei that enables EVM clients to query and execute CosmWasm contracts. Refer to the docs on precompiles for more details.
First, import the address and ABI of the CosmWasm precompile from @sei-js/evm
.
@sei-js
contains NPM libraries for writing applications that interact with Sei. Learn more here (opens in a new tab).@sei-js/evm
is an npm package that contains useful constants and helpers for
interacting with the EVM on Sei.
To install sei-js:
npm install @sei-js/evm
At the top of App.tsx
you can then import WASM_PRECOMPILE_ADDRESS
,
WASM_PRECOMPILE_ABI
. These constants allow us to interact with the Wasm
Precompile.
import { WASM_PRECOMPILE_ADDRESS, WASM_PRECOMPILE_ABI, WasmPrecompileContract } from '@sei-js/evm';
import { ethers } from 'ethers';
These values will be used in the app to query and execute a contract.
Connecting to the Wallet and Initializing the Contract
Replace your main App
component with the following:
import { WASM_PRECOMPILE_ADDRESS, SEI_CHAIN_INFO, getWasmPrecompileEthersV6Contract } from '@sei-js/evm';
import { useEffect, useState } from 'react';
import { BrowserProvider, Contract, toUtf8Bytes, toUtf8String } from 'ethers';
import './App.css';
function App() {
const [count, setCount] = useState<string>();
const [contract, setContract] = useState<Contract>();
const [isIncrementing, setIsIncrementing] = useState(false);
// TODO: Replace this with your CosmWasm contract address here
const COUNTER_CONTRACT_ADDRESS = 'sei14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9sh9m79m';
const fetchCount = async () => {
if (!contract) {
return;
}
// Query to get the count on the counter contract
const queryMsg = { get_count: {} };
const queryResponse = await contract.query(COUNTER_CONTRACT_ADDRESS, toUtf8Bytes(JSON.stringify(queryMsg)));
const { count } = JSON.parse(toUtf8String(queryResponse));
setCount(count);
};
useEffect(() => {
fetchCount();
}, [contract]);
const connectWallet = async () => {
if (window.ethereum) {
const provider = new BrowserProvider(window.ethereum);
const { chainId } = await provider.getNetwork();
const devnetChainId = SEI_CHAIN_INFO.devnet.chainId;
if (chainId !== BigInt(devnetChainId)) {
alert('Wallet is not connected to Sei EVM devnet');
return;
}
const signer = await provider.getSigner();
const contract = getWasmPrecompileEthersV6Contract(signer);
setContract(contract);
} else {
alert('No EVM compatible wallet installed');
}
};
const incrementCount = async () => {
if (!contract) {
return;
}
setIsIncrementing(true);
// Execute message to increment the count on the contract
const executeMsg = { increment: {} };
const executeResponse = await contract.execute(
COUNTER_CONTRACT_ADDRESS,
toUtf8Bytes(JSON.stringify(executeMsg)),
toUtf8Bytes(JSON.stringify([])) // Used for sending funds if needed
);
// Wait for the transaction to be confirmed
await executeResponse.wait();
console.log(executeResponse);
setIsIncrementing(false);
await fetchCount();
};
return (
<>
<div className="card">
{contract ? (
<div>
<h1>Count is {count}</h1>
<button disabled={isIncrementing} onClick={incrementCount}>
{isIncrementing ? 'incrementing...' : 'increment'}
</button>
</div>
) : (
<button onClick={connectWallet}>Connect Wallet</button>
)}
</div>
</>
);
}
export default App;
Detailed outline of App.tsx
State Declarations
count
: Holds the current count fetched from the smart contract.contract
: An instance of the ethers Contract object, used for interacting with the blockchain.isIncrementing
: A boolean to manage UI state during contract execution
Effect Hooks
A single useEffect
hook to fetch the current count whenever the contract state
changes, indicating that the contract instance is ready for interaction.
Connecting to EVM Wallet
A function named connectWallet
that:
- Checks for any EVM compatible wallet extension.
- Establishes a connection to the Ethereum network via the connected wallet, using ethers.js BrowserProvider.
- Verifies the correct network (Sei EVM devnet) by comparing chainId.
- Creates an ethers.js Contract instance with the signer from the wallet, setting it in the contract state for later use.
Fetching Contract Data
A function named fetchCount
that:
- Executes a contract query to get the current count.
- Parses and updates the count state with the response.
Incrementing the Counter
A function named incrementCount
that:
- Sends a transaction to the smart contract to increment the count.
- Waits for the transaction to be confirmed.
- Refetches the count to update the UI with the new value.
To see your app in action, run npm run dev
to spin up a local version of the
application. Once you connect your wallet, you should see a counter, as well as
a button you can use to increment the counter on the contract.
Congrats on deploying your first interoperable dApp on Sei!
🎉 Congratulations on creating a website for querying and executing a smart contract on Sei! Explore more possibilities with your frontend at our @sei-js repo (opens in a new tab).
Beyond Simple Contracts: Advanced CosmWasm Interactions
While our counter example demonstrated the basics of connecting to and interacting with a CosmWasm contract through the WASM precompile, CosmWasm contracts offer much more sophisticated capabilities. One of their most powerful features is their ability to describe their own interfaces, eliminating the need for external ABIs (Application Binary Interfaces) that are typically required for EVM contract interactions.
Let's explore these advanced features by building a component that interacts with a CW721 (NFT) contract. This example will demonstrate contract discovery, handling different response formats, and implementing robust error handling - skills that are essential when working with more complex CosmWasm contracts.
Advanced CosmWasm Contract Interactions Through the WASM Precompile
Introduction
While our basic counter example demonstrated simple contract interactions, CosmWasm contracts offer much more sophisticated capabilities than traditional EVM contracts. One of their most powerful features is their ability to describe their own interfaces, eliminating the need for external ABIs (Application Binary Interfaces) that are required for EVM contract interactions.
In this guide, we'll explore these advanced features and learn how to build more complex applications that leverage the full power of CosmWasm contracts through Sei's WASM precompile.
Understanding CosmWasm Contract Discovery
Unlike EVM contracts where you need detailed interface specifications beforehand, CosmWasm contracts can tell you exactly how to interact with them. This self-describing capability makes them particularly developer-friendly and reduces the chance of interface mismatches.
Let's explore how to implement this discovery mechanism:
async function discoverContractMethods(contractAddress: string) {
// We intentionally send an invalid query - the contract will respond with valid methods
const invalidQuery = { a: 'b' };
try {
await contract.query(contractAddress, toUtf8Bytes(JSON.stringify(invalidQuery)));
// If we reach here, something unexpected happened
console.log('Unexpected success - contract accepted invalid query');
return null;
} catch (error) {
if (error.data) {
const errorMessage = toUtf8String(error.data);
// The error message contains a list of valid methods
// Format: "expected one of method1, method2, method3: query wasm contract failed"
const match = errorMessage.match(/expected one of (.+): query wasm contract failed/);
if (match) {
const validMethods = match[1]
.replace(/`/g, '')
.split(', ')
.map((method) => method.trim());
console.log('Valid query methods:', validMethods);
return validMethods;
}
}
console.error('Unexpected error structure:', error);
return null;
}
}
When we run this against a CW721 (NFT) contract, we might receive a response like this:
[
'owner_of', // Query the owner of a specific token
'approval', // Check if an address is approved for a token
'approvals', // List all approvals for a token
'operator', // Check if an address is an operator
'all_operators', // List all operators
'num_tokens', // Get total supply of tokens
'contract_info', // Get contract metadata
'nft_info', // Get metadata for a specific token
'all_nft_info', // Get all info for a specific token
'tokens', // List tokens owned by an address
'all_tokens', // List all tokens in the collection
'minter', // Get the minting authority
'extension', // Access CW721 extensions
'ownership' // Query contract ownership
];
Building an NFT Information Component
Let's create a practical example that uses these discovered methods to interact with a CW721 contract. This component will display both collection-wide information and individual token details:
function NFTViewer() {
const [methods, setMethods] = useState<string[]>([]);
const [collectionOwner, setCollectionOwner] = useState<string>();
const [tokenOwner, setTokenOwner] = useState<string>();
const [contract, setContract] = useState<Contract>();
const [tokenId, setTokenId] = useState<string>('1');
const [isLoading, setIsLoading] = useState(false);
const NFT_CONTRACT_ADDRESS = "sei1g2a0q3tddzs7vf7lk45c2tgufsaqerxmsdr2cprth3mjtuqxm60qdmravc";
// Query collection ownership
const queryCollectionOwner = async () => {
try {
// The ownership query returns collection-level ownership information
const queryMsg = { ownership: {} };
const queryResponse = await contract.query(
NFT_CONTRACT_ADDRESS,
toUtf8Bytes(JSON.stringify(queryMsg))
);
const responseData = JSON.parse(toUtf8String(queryResponse));
// Handle different response formats - some contracts nest data differently
const owner = responseData.data?.owner || responseData.owner;
setCollectionOwner(owner);
} catch (error) {
console.error('Error querying collection owner:', error);
}
};
// Query specific token ownership
const queryTokenOwner = async (tokenId: string) => {
try {
setIsLoading(true);
// The owner_of query returns ownership information for a specific token
const queryMsg = { owner_of: { token_id: tokenId } };
const queryResponse = await contract.query(
NFT_CONTRACT_ADDRESS,
toUtf8Bytes(JSON.stringify(queryMsg))
);
const responseData = JSON.parse(toUtf8String(queryResponse));
const owner = responseData.data?.owner || responseData.owner;
setTokenOwner(owner);
} catch (error) {
console.error('Error querying token owner:', error);
} finally {
setIsLoading(false);
}
};
// Discover available methods when component mounts or contract changes
useEffect(() => {
if (!contract) return;
discoverContractMethods(NFT_CONTRACT_ADDRESS)
.then(setMethods);
}, [contract]);
return (
<div className="nft-viewer">
<h2>NFT Collection Info</h2>
<div>
<p>Collection Address: {NFT_CONTRACT_ADDRESS}</p>
<p>Collection Owner: {collectionOwner || 'Loading...'}</p>
<p>Available Methods: {methods?.join(', ') || 'Loading...'}</p>
</div>
<div className="token-lookup">
<h3>Token Ownership Lookup</h3>
<div className="input-group">
<input
type="text"
value={tokenId}
onChange={(e) => setTokenId(e.target.value)}
placeholder="Enter token ID"
/>
<button
onClick={() => queryTokenOwner(tokenId)}
disabled={isLoading}
>
{isLoading ? 'Searching...' : 'Look Up Token'}
</button>
</div>
{tokenOwner && (
<div className="token-result">
<p>Token {tokenId} Owner: {tokenOwner}</p>
</div>
)}
</div>
</div>
);
}
Understanding CosmWasm Response Formats
CosmWasm contracts can return data in various formats, and it's important to handle these variations properly. Here are some example responses you might receive:
- Collection Ownership Query Response:
{
"owner": "sei1hjsqrfdg2hvwl3gacg4fkznurf36usrv7rkzkyh29wz3guuzeh0snslz7d",
"pending_owner": null,
"pending_expiry": null
}
- Token Ownership Query Response:
{
"owner": "sei1frcndtm928xln5awxz4rcrh3f5exskjczrc92f",
"approvals": []
}
Notice how these responses have different structures. That's why our code uses a flexible approach to extract the owner:
const owner = responseData.data?.owner || responseData.owner;
Creating a Reusable Query Function
To make contract interactions more maintainable, consider creating a reusable query function:
async function queryContract(address: string, queryMsg: object) {
try {
const response = await contract.query(address, toUtf8Bytes(JSON.stringify(queryMsg)));
const result = JSON.parse(toUtf8String(response));
// Handle both nested and direct data structures
return result.data ?? result;
} catch (error) {
if (error.data) {
const errorMessage = toUtf8String(error.data);
// Check if this is a method discovery error
if (errorMessage.includes('expected one of')) {
console.log('Available methods:', errorMessage);
}
}
throw error;
}
}
Best Practices for CosmWasm Contract Interactions
When building applications that interact with CosmWasm contracts through the WASM precompile, follow these guidelines:
-
Always Start with Discovery: Use the contract's self-describing capabilities to understand its interface. This prevents errors and makes your code more maintainable.
-
Handle Response Variations: CosmWasm contracts might return data in different formats. Always implement robust response parsing that can handle various structures.
-
Implement Proper Error Handling: CosmWasm contracts provide detailed error messages that can help diagnose issues. Make use of this information in your error handling.
-
Manage Loading States: Always provide clear feedback about the state of contract interactions to improve user experience.
-
Cache Method Information: Consider caching the discovered methods to reduce unnecessary queries, but make sure to refresh this cache periodically.
Error Handling Examples
Here's how to handle different types of errors you might encounter:
try {
const result = await queryContract(address, queryMsg);
// Handle success
} catch (error) {
if (error.data) {
const errorMessage = toUtf8String(error.data);
if (errorMessage.includes('expected one of')) {
// This is a method discovery error - might be useful!
console.log('Valid methods:', errorMessage);
} else if (errorMessage.includes('not found')) {
// Handle non-existent tokens or resources
console.log('Resource not found');
} else {
// Handle other contract-specific errors
console.log('Contract error:', errorMessage);
}
} else {
// Handle network or other errors
console.error('Network or system error:', error);
}
}
Conclusion
CosmWasm contracts offer powerful capabilities that go beyond traditional EVM contracts. Their self-describing nature and flexible query system make them particularly well-suited for building robust and maintainable applications. By understanding and leveraging these features through the WASM precompile, you can create more sophisticated and user-friendly applications on the Sei network.