Connect Your Contracts to Frontend
š This tutorial builds upon Challenge 1 where you deployed and verified ERC20 token and ERC721 NFT contracts on Lisk Sepolia.
š You'll now connect these smart contracts to a React/Next.js frontend with wallet integration, allowing users to interact with your deployed contracts through a beautiful Web3 interface!
š The final deliverable is a fully functional dApp deployed to Vercel/Netlify that connects to your verified contracts, enabling token transfers and NFT minting with proper wallet integration.
Challenge Overview
Connect your smart contracts from Week 1 to a React/Next.js frontend with wallet integration.
Key Requirements
- Create a React/Next.js application using Scaffold-Lisk
- Connect to user's wallet (recommended using Rabby Wallet)
- Display token balance and NFT ownership
- Allow users to mint NFTs and transfer tokens
- Deploy frontend to Vercel/Netlify
Learning Objectives
- Web3 frontend development
- Wallet integration patterns
- Contract interaction via JavaScript
- State management for Web3 apps
š¬ Meet other builders working on this challenge and get help in the @LiskSEA Telegram!
Checkpoint 0: š¦ Prerequisites š
ā ļø Important: You must complete Challenge 1 first!
Before you begin, ensure you have:
- ā Completed Challenge 1: Deployed and verified MyToken and MyNFT contracts
- ā Contract addresses: Your deployed contract addresses from Challenge 1
- ā Verified contracts: Both contracts verified on Lisk Sepolia Blockscout
- ā Scaffold-Lisk setup: Your existing Scaffold-Lisk environment from Challenge 1
Navigate to your Scaffold-Lisk project directory and start the development server:
yarn start
š± Open http://localhost:3000 to see the app.
Checkpoint 1: š§ Configure Your Contracts
āļø Let's connect your deployed contracts to the frontend!
Import Your Contract ABIs
Your contracts from Challenge 1 are already deployed and verified. The Scaffold-Lisk framework will automatically generate TypeScript ABIs from your contract files when you build the project.
Verify Network Configuration
Your packages/nextjs/scaffold.config.ts should already be configured for Lisk Sepolia from Challenge 1. Verify it contains:
// The networks on which your DApp is live
targetNetworks: [chains.liskSepolia],
If not already configured, update the targetNetworks to point to Lisk Sepolia.
Connect to Your Deployed Contracts
Your contracts from Challenge 1 are already deployed to Lisk Sepolia. The frontend will automatically connect to them using the contract addresses from your previous deployment.
If you need to update contract addresses, check
packages/hardhat/deployments/liskSepolia/directory for your deployment artifacts.
š” Key Concept: Contract Discovery
Scaffold-Lisk automatically finds your contracts by:
- Reading deployment artifacts from
deployments/[network]/- Generating TypeScript types from contract ABIs
- Making contracts available via
useScaffoldContract*hooksThis means you don't need to manually copy-paste contract addresses or ABIs!
Checkpoint 2: š¦ Wallet Integration & Connection
š Let's set up wallet connections using RainbowKit!
Scaffold-Lisk comes pre-configured with RainbowKit for wallet connections. Let's verify and test the wallet integration.
Test Wallet Connection
Your wallet should already be configured for Lisk Sepolia from Challenge 1.
- Test Connection:
- Click "Connect Wallet" in the top right
- Select your wallet and connect your account
- You should see your address and balance displayed
ā½ļø Ensure you have Lisk Sepolia ETH for gas fees (you should have some remaining from Challenge 1).
š Key Concept: Wallet Security
When you connect your wallet:
- Read permissions: dApp can see your address and balance
- Transaction approval: You must approve each transaction individually
- Private keys: Never shared with the dApp (stay in your wallet)
- Network switching: Wallet can prompt you to switch networks automatically
Always verify transaction details before signing!
Understanding Scaffold-Lisk Hooks š§
Before building our components, it's important to understand the key hooks that Scaffold-Lisk provides to simplify blockchain interactions:
useScaffoldContractRead Hook
This hook automatically handles reading data from your smart contracts:
const { data: tokenBalance } = useScaffoldContractRead({
contractName: "MyToken", // Contract name from your deployment
functionName: "balanceOf", // Contract function to call
args: [userAddress], // Function arguments
});
What it does:
- ā Auto-loads contract: Finds your contract ABI and address automatically
- ā Real-time updates: Watches for changes and updates data automatically
- ā Type safety: Provides TypeScript types for function arguments and return values
- ā Error handling: Built-in error states and loading indicators
- ā Network aware: Automatically connects to the correct network
useScaffoldContractWrite Hook
This hook handles writing transactions to your smart contracts:
const { writeAsync: writeMyTokenAsync } = useScaffoldContractWrite({
contractName: "MyToken",
functionName: "transfer",
});
// Later in your component
await writeMyTokenAsync({
args: [recipient, amount],
});
What it does:
- ā Transaction management: Handles the entire transaction lifecycle
- ā User notifications: Shows success/error messages automatically
- ā Network validation: Ensures user is on correct network before sending
- ā Gas estimation: Estimates gas costs before transaction
- ā
Loading states: Provides
isMiningstate for UI feedback
useAccount Hook (from Wagmi)
This hook manages wallet connection state:
const { address: connectedAddress } = useAccount();
What it provides:
- ā Connection status: Whether wallet is connected
- ā User address: The connected wallet address
- ā Account info: Balance, ENS name, and other account details
Key Benefits of These Hooks
šÆ Simplified Development: No need to manually manage contract ABIs, addresses, or connection logic
š± Better UX: Built-in loading states, error handling, and user notifications
š Type Safety: Full TypeScript support prevents common errors
ā” Performance: Automatic caching and optimized re-renders
š Multi-network: Easy switching between different blockchain networks
Architecture Overview šļø
Before we build components, let's understand how everything connects:
Web3 dApp Architecture Flow
Key Interactions Explained
1. Wallet Connection Flow:
User clicks "Connect Wallet" ā RainbowKit modal opens ā User selects wallet ā Wallet prompts for connection ā useAccount() hook provides connected address ā Components can now interact with blockchain
2. Reading Contract Data:
Component mounts ā useScaffoldContractRead() calls contract ā Blockchain returns data ā Hook updates component state ā UI shows real-time balance/NFT count ā Hook continues watching for changes
3. Writing to Contracts (Transactions):
User fills form ā clicks button ā useScaffoldContractWrite() ā Wallet prompts for signature ā Transaction sent to mempool ā Block confirmations ā Success notification ā UI updates
Component Responsibilities
| Component | Purpose | Key Features |
|---|---|---|
| TokenBalance | Display token info | Real-time balance, token name/symbol |
| TokenTransfer | Send tokens | Input validation, transaction handling |
| NFTCollection | Mint & display NFTs | Total supply, user balance, minting |
State Management Pattern
Each component follows this pattern:
- Check wallet connection (
useAccount) - Read contract data (
useScaffoldContractRead) - Handle user input (React state)
- Write to contract (
useScaffoldContractWrite) - Show feedback (loading, success, error)
Checkpoint 3: šŖ Build Token Interface
š° Create components to display and interact with your ERC20 token!
Create Token Balance Component
Create packages/nextjs/components/example-ui/TokenBalance.tsx:
"use client";
import { useAccount } from "wagmi";
import { Address } from "~~/components/scaffold-eth";
import { useScaffoldContractRead } from "~~/hooks/scaffold-eth";
export const TokenBalance = () => {
const { address: connectedAddress } = useAccount();
const { data: tokenBalance } = useScaffoldContractRead({
contractName: "MyToken",
functionName: "balanceOf",
args: [connectedAddress],
});
const { data: tokenSymbol } = useScaffoldContractRead({
contractName: "MyToken",
functionName: "symbol",
});
const { data: tokenName } = useScaffoldContractRead({
contractName: "MyToken",
functionName: "name",
});
if (!connectedAddress) {
return (
<div className="card w-96 bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">Token Balance</h2>
<p>Please connect your wallet to view token balance</p>
</div>
</div>
);
}
return (
<div className="card w-96 bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">
{tokenName} ({tokenSymbol})
</h2>
<div className="stats">
<div className="stat">
<div className="stat-title">Your Balance</div>
<div className="stat-value text-primary">
{tokenBalance ? (Number(tokenBalance) / 1e18).toFixed(4) : "0.0000"}
</div>
<div className="stat-desc">{tokenSymbol}</div>
</div>
</div>
<div className="card-actions justify-end">
<Address address={connectedAddress} />
</div>
</div>
</div>
);
};
š§ Understanding the TokenBalance Component
Let's break down this component to understand how it works:
Key Imports Explained
import { useAccount } from "wagmi";
// Wallet connection state
import { Address, Balance } from "~~/components/scaffold-eth";
// Pre-built UI components
import { useScaffoldContractRead } from "~~/hooks/scaffold-eth";
// Contract reading hook
Data Fetching Pattern
// 1. Get connected wallet address
const { address: connectedAddress } = useAccount();
// 2. Read token balance for connected address
const { data: tokenBalance } = useScaffoldContractRead({
contractName: "MyToken",
functionName: "balanceOf",
args: [connectedAddress], // Pass user's address as argument
});
// 3. Read token metadata (name & symbol)
const { data: tokenSymbol } = useScaffoldContractRead({
contractName: "MyToken",
functionName: "symbol",
});
š Real-time Updates: These hooks automatically watch the blockchain and update when:
- User receives tokens
- User sends tokens
- New blocks are mined
Conditional Rendering Pattern
if (!connectedAddress) {
return <div>Please connect your wallet</div>;
}
This ensures the component only tries to fetch data when a wallet is connected.
Data Display & Formatting
// Convert from Wei (18 decimals) to human-readable format
{
tokenBalance ? (Number(tokenBalance) / 1e18).toFixed(4) : "0.0000";
}
š” Why divide by 1e18? ERC20 tokens store values in "Wei" (smallest unit). Most tokens use 18 decimal places, so we divide by 10^18 to show the actual token amount.
UI Framework (DaisyUI)
The component uses DaisyUI classes for styling:
card w-96 bg-base-100 shadow-xl: Creates a styled card containerstat,stat-title,stat-value: Statistics display componentstext-primary: Theme-aware color styling
Create Token Transfer Component
Create packages/nextjs/components/example-ui/TokenTransfer.tsx:
"use client";
import { useState } from "react";
import { parseEther } from "viem";
import { useAccount } from "wagmi";
import { useScaffoldContractWrite } from "~~/hooks/scaffold-eth";
import { notification } from "~~/utils/scaffold-eth";
export const TokenTransfer = () => {
const { address: connectedAddress } = useAccount();
const [recipient, setRecipient] = useState("");
const [amount, setAmount] = useState("");
const { writeAsync: writeMyTokenAsync } = useScaffoldContractWrite({
contractName: "MyToken",
functionName: "transfer",
args: [recipient, parseEther(amount)],
});
const handleTransfer = async () => {
if (!recipient || !amount) {
notification.error("Please fill in all fields");
return;
}
try {
await writeMyTokenAsync({
args: [recipient, parseEther(amount)],
});
notification.success("Token transfer successful!");
setRecipient("");
setAmount("");
} catch (error) {
console.error("Transfer failed:", error);
notification.error("Transfer failed. Please try again.");
}
};
if (!connectedAddress) {
return (
<div className="card w-96 bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">Transfer Tokens</h2>
<p>Please connect your wallet to transfer tokens</p>
</div>
</div>
);
}
return (
<div className="card w-96 bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">Transfer Tokens</h2>
<div className="form-control w-full max-w-xs">
<label className="label">
<span className="label-text">Recipient Address</span>
</label>
<input
type="text"
placeholder="0x..."
className="input input-bordered w-full max-w-xs"
value={recipient}
onChange={e => setRecipient(e.target.value)}
/>
</div>
<div className="form-control w-full max-w-xs">
<label className="label">
<span className="label-text">Amount</span>
</label>
<input
type="number"
placeholder="0.0"
className="input input-bordered w-full max-w-xs"
value={amount}
onChange={e => setAmount(e.target.value)}
/>
</div>
<div className="card-actions justify-end">
<button className="btn btn-primary" onClick={handleTransfer} disabled={!recipient || !amount}>
Transfer
</button>
</div>
</div>
</div>
);
};
š§ Understanding the TokenTransfer Component
This component demonstrates transaction handling and user input management:
State Management for User Input
const [recipient, setRecipient] = useState(""); // Recipient wallet address
const [amount, setAmount] = useState(""); // Token amount to send
Contract Write Hook Setup
const { writeAsync: writeMyTokenAsync } = useScaffoldContractWrite({
contractName: "MyToken",
functionName: "transfer",
});
This gives us a function to call contract methods that modify state (require transactions).
Transaction Flow Breakdown
const handleTransfer = async () => {
// 1. Input validation
if (!recipient || !amount) {
notification.error("Please fill in all fields");
return;
}
try {
// 2. Send transaction
await writeMyTokenAsync({
args: [recipient, parseEther(amount)], // Convert to Wei
});
// 3. Success feedback
notification.success("Token transfer successful!");
setRecipient(""); // Clear form
setAmount("");
} catch (error) {
// 4. Error handling
notification.error("Transfer failed. Please try again.");
}
};
Key Concepts Explained
š parseEther() Function:
parseEther(amount); // Converts "1.5" ā "1500000000000000000"
This converts human-readable amounts to Wei (blockchain's smallest unit).
ā½ Transaction Lifecycle:
- User clicks "Transfer" ā Wallet prompts for signature
- User signs ā Transaction sent to mempool
- Miners include transaction in block ā Confirmation
useScaffoldContractWriteautomatically shows notifications
ā Input Validation:
- Button disabled until both fields filled
- Client-side validation before sending transaction
- Server-side validation happens in smart contract
User Experience Features
- Real-time button state: Disabled when form incomplete
- Automatic notifications: Success/error messages handled automatically
- Form reset: Clears inputs after successful transfer
- Error resilience: Catches and displays transaction failures
Security Considerations
- Input validation prevents empty transactions
parseEtherprevents decimal precision errors- Smart contract enforces balance checks
- User must explicitly sign each transaction
Checkpoint 4: šØ Build NFT Interface
š¼ļø Create components to display and mint your NFTs!
Create NFT Collection Component
Create packages/nextjs/components/example-ui/NFTCollection.tsx:
"use client";
import { useState } from "react";
import { useAccount } from "wagmi";
import { Address } from "~~/components/scaffold-eth";
import { useScaffoldContractRead, useScaffoldContractWrite } from "~~/hooks/scaffold-eth";
import { notification } from "~~/utils/scaffold-eth";
export const NFTCollection = () => {
const { address: connectedAddress } = useAccount();
const [mintToAddress, setMintToAddress] = useState("");
const { data: nftName } = useScaffoldContractRead({
contractName: "MyNFT",
functionName: "name",
});
const { data: nftSymbol } = useScaffoldContractRead({
contractName: "MyNFT",
functionName: "symbol",
});
const { data: totalSupply } = useScaffoldContractRead({
contractName: "MyNFT",
functionName: "totalSupply",
});
const { data: userBalance } = useScaffoldContractRead({
contractName: "MyNFT",
functionName: "balanceOf",
args: [connectedAddress],
});
const { writeAsync: writeMyNFTAsync } = useScaffoldContractWrite({
contractName: "MyNFT",
functionName: "mint",
args: [mintToAddress || connectedAddress],
});
const handleMint = async () => {
const targetAddress = mintToAddress || connectedAddress;
if (!targetAddress) {
notification.error("Please connect wallet or specify address");
return;
}
try {
await writeMyNFTAsync({
args: [targetAddress],
});
notification.success("NFT minted successfully!");
setMintToAddress("");
} catch (error) {
console.error("Mint failed:", error);
notification.error("Minting failed. Please try again.");
}
};
if (!connectedAddress) {
return (
<div className="card w-96 bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">NFT Collection</h2>
<p>Please connect your wallet to view and mint NFTs</p>
</div>
</div>
);
}
return (
<div className="card w-96 bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">
{nftName} ({nftSymbol})
</h2>
<div className="stats">
<div className="stat">
<div className="stat-title">Total Minted</div>
<div className="stat-value text-secondary">{totalSupply?.toString() || "0"}</div>
</div>
<div className="stat">
<div className="stat-title">You Own</div>
<div className="stat-value text-accent">{userBalance?.toString() || "0"}</div>
</div>
</div>
<div className="form-control w-full max-w-xs">
<label className="label">
<span className="label-text">Mint to address (leave empty for yourself)</span>
</label>
<input
type="text"
placeholder="0x... or leave empty"
className="input input-bordered w-full max-w-xs"
value={mintToAddress}
onChange={e => setMintToAddress(e.target.value)}
/>
</div>
<div className="card-actions justify-end">
<button className="btn btn-primary" onClick={handleMint}>
Mint NFT
</button>
</div>
<div className="text-sm text-gray-600">
<Address address={connectedAddress} />
</div>
</div>
</div>
);
};
š§ Understanding the NFTCollection Component
This component demonstrates NFT interactions and collection management:
Multiple Contract Reads
// Reading various NFT contract properties
const { data: nftName } = useScaffoldContractRead({
contractName: "MyNFT",
functionName: "name",
});
const { data: totalSupply } = useScaffoldContractRead({
contractName: "MyNFT",
functionName: "totalSupply", // Total NFTs minted
});
const { data: userBalance } = useScaffoldContractRead({
contractName: "MyNFT",
functionName: "balanceOf",
args: [connectedAddress], // How many NFTs user owns
});
Smart Minting Logic
const handleMint = async () => {
// Allow minting to self or specified address
const targetAddress = mintToAddress || connectedAddress;
await writeMyNFTAsync({
args: [targetAddress], // Mint to target address
});
};
Key Features Explained
š Statistics Display:
- Total Minted: Shows how many NFTs exist in the collection
- You Own: Shows user's personal NFT count
- Real-time updates when new NFTs are minted
šÆ Flexible Minting:
// If input empty, mint to self
// If input provided, mint to that address
const targetAddress = mintToAddress || connectedAddress;
š¢ Data Type Handling:
{
totalSupply?.toString() || "0";
}
Smart contracts return BigNumber types, so we convert to string for display.
NFT Standards (ERC721)
This component works with ERC721 NFTs which have these key properties:
- Unique tokens: Each NFT has a unique token ID
- Ownership tracking:
balanceOf()shows how many NFTs an address owns - Transferable: NFTs can be sent between wallets
- Metadata: Each NFT can have associated metadata (images, properties)
Update Main Page
Edit packages/nextjs/app/page.tsx to include your new components:
"use client";
import type { NextPage } from "next";
import { NFTCollection } from "~~/components/example-ui/NFTCollection";
import { TokenBalance } from "~~/components/example-ui/TokenBalance";
import { TokenTransfer } from "~~/components/example-ui/TokenTransfer";
const Home: NextPage = () => {
return (
<>
<div className="flex items-center flex-col flex-grow pt-10">
<div className="flex-grow bg-base-300 w-full mt-16 px-8 py-12">
<div className="flex justify-center items-center gap-6 flex-col sm:flex-row">
<div className="flex flex-col px-10 py-10 text-center items-center rounded-3xl">
<TokenBalance />
</div>
<div className="flex flex-col px-10 py-10 text-center items-center rounded-3xl">
<TokenTransfer />
</div>
<div className="flex flex-col px-10 py-10 text-center items-center rounded-3xl">
<NFTCollection />
</div>
</div>
</div>
</div>
</>
);
};
export default Home;
Checkpoint 5: š Deploy to Production
š Deploy your dApp to Vercel for the world to use!
Prepare for Deployment
-
Verify Contract Deployment: Ensure your contracts from Challenge 1 are properly deployed and verified.
-
Environment Variables: Create
.env.localfor sensitive data:
NEXT_PUBLIC_ALCHEMY_API_KEY=your_alchemy_api_key
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=your_wallet_connect_project_id
š Key Concept: Environment Variables
NEXTPUBLIC prefix makes variables available to browser:
- ā
NEXT_PUBLIC_ALCHEMY_API_KEY- Safe for client-side- ā
PRIVATE_KEY- Server-only, never use NEXTPUBLIC for secretsWhy separate environments?
- Development: Uses
.env.local(not committed to git)- Production: Set via Vercel dashboard (secure)
- Different networks: Easy to switch API endpoints
Deploy to Vercel
- Push to GitHub:
git add .
git commit -m "feat: add Web3 frontend for token and NFT contracts"
git push origin main
-
Deploy on Vercel:
- Go to vercel.com
- Connect your GitHub repository
- Configure environment variables in Vercel dashboard
- Deploy!
- For more details: https://vercel.com/docs/git
-
Alternative: Deploy to Netlify:
- Build the app:
yarn build - Go to netlify.com
- Drag and drop your
distfolder - Configure environment variables
- For more details: https://www.netlify.com/blog/2016/09/29/a-step-by-step-guide-deploying-on-netlify/
- Build the app:
Test Your Deployed dApp
- ā Connect wallet on live site
- ā Check token balance displays correctly
- ā Test token transfer functionality
- ā Verify NFT minting works
- ā Confirm all transactions appear on Lisk Sepolia Blockscout
Checkpoint 6: š Submit Your Challenge
šÆ Time to submit your completed Week 2 challenge!
Go to Week 2 Submission and submit:
- ā Frontend URL: Your deployed Vercel/Netlify URL
- ā Contract Addresses: Your MyToken and MyNFT contract addresses from Week 1
- ā GitHub Repository: Link to your code repository
š” Advanced Development Tips
š§ Development Best Practices
Local Testing Strategy:
# Terminal 1: Always run local chain first
yarn chain
# Terminal 2: Deploy contracts to local network
yarn deploy
# Terminal 3: Start frontend
yarn start
Debugging Contract Interactions:
// Add console.logs to understand hook behavior
const {
data: balance,
error,
isLoading,
} = useScaffoldContractRead({
contractName: "MyToken",
functionName: "balanceOf",
args: [address],
});
console.log("Balance data:", balance);
console.log("Is loading:", isLoading);
console.log("Error:", error);
ā” Performance Optimization
Minimize Re-renders:
// ā
Good: Specific hooks for each data point
const { data: tokenName } = useScaffoldContractRead({
contractName: "MyToken",
functionName: "name",
});
// ā Bad: Single hook for multiple calls
// This causes unnecessary re-renders
Handle Loading States:
const { data: balance, isLoading } = useScaffoldContractRead({...});
if (isLoading) return <div className="loading loading-spinner"></div>;
if (!balance) return <div>No balance found</div>;
š¬ Problems, questions, comments on the stack? Post them to @LiskSEA