Building

Your First dApp

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

  1. Vercel: Connect GitHub repo, auto-deploy
  2. Netlify: Drag & drop dist folder
  3. GitHub Pages: Push to gh-pages branch
  4. 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:

  1. Add ENS support - Resolve names to addresses
  2. Improve UX - Loading states, error handling
  3. Add notifications - Toast on successful tip
  4. Mobile responsive - CSS media queries
  5. Gasless transactions - ERC-4337 or relayers
  6. Multi-chain - Deploy to multiple networks

Key Takeaways

  1. Contract → Test → Deploy → Verify is the workflow
  2. Wagmi + RainbowKit makes wallet connection easy
  3. useReadContract for view functions
  4. useWriteContract for state-changing functions
  5. Events enable real-time updates
  6. Guard against edge cases - wrong network, not connected
  7. Start on testnet before mainnet