Building Your First dApp
Module 2 of Building
What We're Building
A simple Tip Jar dApp:
- Users can send ETH tips
- Owner can withdraw tips
- Shows tip history
- Displays total received
This covers all fundamentals: state, events, access control, and frontend integration.
Part 1: Smart Contract
The Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract TipJar {
address public owner;
uint256 public totalTips;
// Events for frontend to track
event TipReceived(address indexed sender, uint256 amount, string message);
event Withdrawn(address indexed owner, uint256 amount);
// Store tip history
struct Tip {
address sender;
uint256 amount;
string message;
uint256 timestamp;
}
Tip[] public tips;
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
// Receive tips with a message
function tip(string calldata message) external payable {
require(msg.value > 0, "Must send ETH");
tips.push(Tip({
sender: msg.sender,
amount: msg.value,
message: message,
timestamp: block.timestamp
}));
totalTips += msg.value;
emit TipReceived(msg.sender, msg.value, message);
}
// Owner withdraws all tips
function withdraw() external onlyOwner {
uint256 balance = address(this).balance;
require(balance > 0, "No funds");
(bool success, ) = owner.call{value: balance}("");
require(success, "Transfer failed");
emit Withdrawn(owner, balance);
}
// Get all tips
function getTips() external view returns (Tip[] memory) {
return tips;
}
// Allow receiving ETH directly
receive() external payable {
tips.push(Tip({
sender: msg.sender,
amount: msg.value,
message: "",
timestamp: block.timestamp
}));
totalTips += msg.value;
emit TipReceived(msg.sender, msg.value, "");
}
}
Test the Contract
// test/TipJar.t.sol
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/TipJar.sol";
contract TipJarTest is Test {
TipJar tipJar;
address owner = address(this);
address tipper = address(0x1);
function setUp() public {
tipJar = new TipJar();
vm.deal(tipper, 10 ether);
}
function test_Tip() public {
vm.prank(tipper);
tipJar.tip{value: 1 ether}("Great content!");
assertEq(tipJar.totalTips(), 1 ether);
assertEq(address(tipJar).balance, 1 ether);
}
function test_Withdraw() public {
vm.prank(tipper);
tipJar.tip{value: 1 ether}("Thanks!");
uint256 ownerBalanceBefore = owner.balance;
tipJar.withdraw();
assertEq(owner.balance, ownerBalanceBefore + 1 ether);
assertEq(address(tipJar).balance, 0);
}
function testFail_WithdrawNotOwner() public {
vm.prank(tipper);
tipJar.tip{value: 1 ether}("Tip");
vm.prank(tipper);
tipJar.withdraw(); // Should fail
}
}
Deploy Script
// script/Deploy.s.sol
pragma solidity ^0.8.20;
import "forge-std/Script.sol";
import "../src/TipJar.sol";
contract DeployScript is Script {
function run() public {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
TipJar tipJar = new TipJar();
console.log("TipJar deployed to:", address(tipJar));
vm.stopBroadcast();
}
}
Deploy to Sepolia
# Run tests first
forge test -vvv
# Deploy
forge script script/Deploy.s.sol \
--rpc-url sepolia \
--broadcast \
--verify
# Note the deployed address!
Part 2: Frontend Setup
Create React App
# Create Vite React app
npm create vite@latest tipjar-frontend -- --template react-ts
cd tipjar-frontend
# Install dependencies
npm install wagmi viem @tanstack/react-query
npm install @rainbow-me/rainbowkit
Project Structure
tipjar-frontend/
├── src/
│ ├── App.tsx
│ ├── main.tsx
│ ├── components/
│ │ ├── TipForm.tsx
│ │ ├── TipList.tsx
│ │ └── WithdrawButton.tsx
│ ├── config/
│ │ └── wagmi.ts
│ └── abi/
│ └── TipJar.json
└── package.json
Part 3: Wallet Connection
Wagmi Config
// src/config/wagmi.ts
import { getDefaultConfig } from '@rainbow-me/rainbowkit';
import { sepolia, mainnet } from 'wagmi/chains';
export const config = getDefaultConfig({
appName: 'TipJar',
projectId: 'YOUR_WALLETCONNECT_PROJECT_ID',
chains: [sepolia, mainnet],
ssr: false,
});
Main Entry
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { WagmiProvider } from 'wagmi';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RainbowKitProvider } from '@rainbow-me/rainbowkit';
import '@rainbow-me/rainbowkit/styles.css';
import { config } from './config/wagmi';
import App from './App';
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider>
<App />
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
</React.StrictMode>,
);
Part 4: Contract ABI
Copy ABI from Foundry
# After compiling, get the ABI
cat out/TipJar.sol/TipJar.json | jq '.abi' > frontend/src/abi/TipJar.json
Contract Address
// src/config/contracts.ts
export const TIPJAR_ADDRESS = '0x...' as const; // Your deployed address
export const TIPJAR_ABI = [...] as const; // ABI from JSON
Part 5: Main App
App Component
// src/App.tsx
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount, useReadContract } from 'wagmi';
import { formatEther } from 'viem';
import { TipForm } from './components/TipForm';
import { TipList } from './components/TipList';
import { WithdrawButton } from './components/WithdrawButton';
import { TIPJAR_ADDRESS, TIPJAR_ABI } from './config/contracts';
function App() {
const { address, isConnected } = useAccount();
const { data: totalTips } = useReadContract({
address: TIPJAR_ADDRESS,
abi: TIPJAR_ABI,
functionName: 'totalTips',
});
const { data: owner } = useReadContract({
address: TIPJAR_ADDRESS,
abi: TIPJAR_ABI,
functionName: 'owner',
});
const isOwner = address?.toLowerCase() === owner?.toLowerCase();
return (
<div className="container">
<h1>Tip Jar</h1>
<ConnectButton />
{totalTips !== undefined && (
<p>Total Tips: {formatEther(totalTips)} ETH</p>
)}
{isConnected && <TipForm />}
<TipList />
{isOwner && <WithdrawButton />}
</div>
);
}
export default App;
Part 6: Components
Tip Form
// src/components/TipForm.tsx
import { useState } from 'react';
import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { parseEther } from 'viem';
import { TIPJAR_ADDRESS, TIPJAR_ABI } from '../config/contracts';
export function TipForm() {
const [amount, setAmount] = useState('0.01');
const [message, setMessage] = useState('');
const { data: hash, writeContract, isPending } = useWriteContract();
const { isLoading: isConfirming, isSuccess } =
useWaitForTransactionReceipt({ hash });
const handleTip = () => {
writeContract({
address: TIPJAR_ADDRESS,
abi: TIPJAR_ABI,
functionName: 'tip',
args: [message],
value: parseEther(amount),
});
};
return (
<div className="tip-form">
<h2>Send a Tip</h2>
<input
type="number"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="Amount in ETH"
/>
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Leave a message..."
/>
<button onClick={handleTip} disabled={isPending || isConfirming}>
{isPending
? 'Confirm in wallet...'
: isConfirming
? 'Confirming...'
: 'Send Tip'}
</button>
{isSuccess && <p>Tip sent! Tx: {hash}</p>}
</div>
);
}
Tip List
// src/components/TipList.tsx
import { useReadContract } from 'wagmi';
import { formatEther } from 'viem';
import { TIPJAR_ADDRESS, TIPJAR_ABI } from '../config/contracts';
export function TipList() {
const { data: tips, isLoading } = useReadContract({
address: TIPJAR_ADDRESS,
abi: TIPJAR_ABI,
functionName: 'getTips',
});
if (isLoading) return <p>Loading tips...</p>;
if (!tips || tips.length === 0) return <p>No tips yet!</p>;
return (
<div className="tip-list">
<h2>Recent Tips</h2>
{tips.map((tip, i) => (
<div key={i} className="tip">
<p>
<strong>{formatEther(tip.amount)} ETH</strong>
from {tip.sender.slice(0, 6)}...{tip.sender.slice(-4)}
</p>
{tip.message && <p>"{tip.message}"</p>}
<p className="timestamp">
{new Date(Number(tip.timestamp) * 1000).toLocaleString()}
</p>
</div>
))}
</div>
);
}
Withdraw Button
// src/components/WithdrawButton.tsx
import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { TIPJAR_ADDRESS, TIPJAR_ABI } from '../config/contracts';
export function WithdrawButton() {
const { data: hash, writeContract, isPending } = useWriteContract();
const { isLoading: isConfirming, isSuccess } =
useWaitForTransactionReceipt({ hash });
const handleWithdraw = () => {
writeContract({
address: TIPJAR_ADDRESS,
abi: TIPJAR_ABI,
functionName: 'withdraw',
});
};
return (
<div className="withdraw">
<h2>Owner Actions</h2>
<button onClick={handleWithdraw} disabled={isPending || isConfirming}>
{isPending
? 'Confirm in wallet...'
: isConfirming
? 'Withdrawing...'
: 'Withdraw All'}
</button>
{isSuccess && <p>Withdrawn! Tx: {hash}</p>}
</div>
);
}
Part 7: Listening to Events
Real-time Updates
// src/hooks/useTipEvents.ts
import { useWatchContractEvent } from 'wagmi';
import { TIPJAR_ADDRESS, TIPJAR_ABI } from '../config/contracts';
export function useTipEvents(onTip: (tip: any) => void) {
useWatchContractEvent({
address: TIPJAR_ADDRESS,
abi: TIPJAR_ABI,
eventName: 'TipReceived',
onLogs(logs) {
logs.forEach((log) => {
const { sender, amount, message } = log.args;
onTip({ sender, amount, message });
});
},
});
}
Use in App
// In App.tsx
import { useTipEvents } from './hooks/useTipEvents';
function App() {
const [newTips, setNewTips] = useState([]);
useTipEvents((tip) => {
setNewTips((prev) => [tip, ...prev]);
// Or refetch the tips list
});
// ...
}
Part 8: Deployment
Build Frontend
npm run build
Deploy Options
- Vercel: Connect GitHub repo, auto-deploy
- Netlify: Drag & drop dist folder
- GitHub Pages: Push to gh-pages branch
- IPFS: Decentralized hosting
IPFS Deployment
# Install IPFS CLI
npm install -g ipfs-deploy
# Deploy
ipd -p pinata dist/
# Returns IPFS hash like: QmX...
# Access at: https://ipfs.io/ipfs/QmX...
Common Issues
Transaction Reverted
// Check the error
const { error } = useWriteContract();
if (error) {
console.log(error.message);
// "execution reverted: Not owner"
}
Wallet Not Connected
// Guard components
if (!isConnected) {
return <p>Please connect wallet</p>;
}
Wrong Network
import { useChainId } from 'wagmi';
import { sepolia } from 'wagmi/chains';
const chainId = useChainId();
if (chainId !== sepolia.id) {
return <p>Please switch to Sepolia</p>;
}
Next Steps
After building this basic dApp:
- Add ENS support - Resolve names to addresses
- Improve UX - Loading states, error handling
- Add notifications - Toast on successful tip
- Mobile responsive - CSS media queries
- Gasless transactions - ERC-4337 or relayers
- Multi-chain - Deploy to multiple networks
Key Takeaways
- Contract → Test → Deploy → Verify is the workflow
- Wagmi + RainbowKit makes wallet connection easy
- useReadContract for view functions
- useWriteContract for state-changing functions
- Events enable real-time updates
- Guard against edge cases - wrong network, not connected
- Start on testnet before mainnet