フロントエンドの役割

スマートコントラクトは単体では一般ユーザーが利用しにくいため、使いやすいフロントエンド(dApp)が必要です。本章では、Next.js、wagmi、RainbowKitを使って、デプロイ済みのGreeterコントラクトと対話するWebアプリケーションを構築します。

使用するライブラリ

ライブラリ役割
Next.jsReactフレームワーク(App Router)
wagmiEthereumのReact Hooks
viem低レベルEthereum操作ライブラリ(wagmiが内部で使用)
RainbowKitウォレット接続UIコンポーネント
TanStack Query非同期状態管理(wagmiが依存)

プロジェクトのセットアップ

# Next.jsプロジェクトの作成
npx create-next-app@latest frontend --typescript --app --tailwind
cd frontend

# wagmi, RainbowKit, 関連パッケージのインストール
npm install wagmi viem @tanstack/react-query
npm install @rainbow-me/rainbowkit

wagmiの設定

wagmiの設定ファイルを作成します。

// src/wagmi.ts
import { getDefaultConfig } from "@rainbow-me/rainbowkit";
import { sepolia, hardhat } from "wagmi/chains";
import { http } from "wagmi";

export const config = getDefaultConfig({
  appName: "Hardhat Tutorial",
  projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || "demo",
  chains: [sepolia, hardhat],
  transports: {
    [sepolia.id]: http(
      `https://eth-sepolia.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_KEY}`
    ),
    [hardhat.id]: http("http://127.0.0.1:8545"),
  },
  ssr: true, // Next.js App Router対応
});

WalletConnect Project IDは、WalletConnect Cloudで無料取得できます。

Providerの設定

アプリケーション全体をProviderで囲みます。

// src/app/providers.tsx
"use client";

import { WagmiProvider } from "wagmi";
import {
  RainbowKitProvider,
  darkTheme,
} from "@rainbow-me/rainbowkit";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "@/wagmi";
import "@rainbow-me/rainbowkit/styles.css";

const queryClient = new QueryClient();

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider
          theme={darkTheme({
            accentColor: "#7b3fe4",
          })}
        >
          {children}
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}
// src/app/layout.tsx
import type { Metadata } from "next";
import { Providers } from "./providers";
import "./globals.css";

export const metadata: Metadata = {
  title: "Hardhat Tutorial dApp",
  description: "Greeter contract frontend",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

コントラクトABIの設定

デプロイしたGreeterコントラクトのABIとアドレスを設定します。

// src/contracts/greeter.ts
export const GREETER_ADDRESS = process.env
  .NEXT_PUBLIC_GREETER_ADDRESS as `0x${string}`;

export const GREETER_ABI = [
  {
    inputs: [{ name: "_greeting", type: "string" }],
    stateMutability: "nonpayable",
    type: "constructor",
  },
  {
    inputs: [],
    name: "greet",
    outputs: [{ name: "", type: "string" }],
    stateMutability: "view",
    type: "function",
  },
  {
    inputs: [{ name: "_greeting", type: "string" }],
    name: "setGreeting",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
  },
] as const;

コントラクト読み取りコンポーネント

wagmiの useReadContract フックでコントラクトの状態を読み取ります。

// src/components/GreetingDisplay.tsx
"use client";

import { useReadContract } from "wagmi";
import { GREETER_ADDRESS, GREETER_ABI } from "@/contracts/greeter";

export function GreetingDisplay() {
  const {
    data: greeting,
    isLoading,
    error,
    refetch,
  } = useReadContract({
    address: GREETER_ADDRESS,
    abi: GREETER_ABI,
    functionName: "greet",
  });

  if (isLoading) return <p>読み込み中...</p>;
  if (error) return <p className="text-red-500">エラー: {error.message}</p>;

  return (
    <div className="p-6 bg-gray-800 rounded-lg">
      <h2 className="text-lg font-semibold mb-2">現在のメッセージ</h2>
      <p className="text-2xl text-green-400 font-mono">
        {greeting}
      </p>
      <button
        onClick={() => refetch()}
        className="mt-4 px-4 py-2 bg-gray-600 rounded hover:bg-gray-500"
      >
        更新
      </button>
    </div>
  );
}

コントラクト書き込みコンポーネント

useWriteContract フックでトランザクションを送信します。

// src/components/GreetingForm.tsx
"use client";

import { useState } from "react";
import {
  useWriteContract,
  useWaitForTransactionReceipt,
} from "wagmi";
import { GREETER_ADDRESS, GREETER_ABI } from "@/contracts/greeter";

export function GreetingForm() {
  const [newGreeting, setNewGreeting] = useState("");

  const {
    writeContract,
    data: hash,
    isPending,
    error,
  } = useWriteContract();

  const {
    isLoading: isConfirming,
    isSuccess,
  } = useWaitForTransactionReceipt({ hash });

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!newGreeting.trim()) return;

    writeContract({
      address: GREETER_ADDRESS,
      abi: GREETER_ABI,
      functionName: "setGreeting",
      args: [newGreeting],
    });
  }

  return (
    <form onSubmit={handleSubmit} className="p-6 bg-gray-800 rounded-lg">
      <h2 className="text-lg font-semibold mb-4">
        メッセージを変更
      </h2>

      <div className="flex gap-2">
        <input
          type="text"
          value={newGreeting}
          onChange={(e) => setNewGreeting(e.target.value)}
          placeholder="新しいメッセージを入力"
          className="flex-1 px-4 py-2 bg-gray-700 rounded
                     text-white placeholder-gray-400"
          disabled={isPending || isConfirming}
        />
        <button
          type="submit"
          disabled={isPending || isConfirming || !newGreeting.trim()}
          className="px-6 py-2 bg-purple-600 rounded font-semibold
                     hover:bg-purple-500 disabled:opacity-50
                     disabled:cursor-not-allowed"
        >
          {isPending
            ? "署名待ち..."
            : isConfirming
            ? "確認中..."
            : "送信"}
        </button>
      </div>

      {/* トランザクション状態の表示 */}
      {hash && (
        <p className="mt-3 text-sm text-gray-400">
          TX:{" "}
          <a
            href={`https://sepolia.etherscan.io/tx/${hash}`}
            target="_blank"
            rel="noopener noreferrer"
            className="text-blue-400 hover:underline"
          >
            {hash.slice(0, 10)}...{hash.slice(-8)}
          </a>
        </p>
      )}

      {isSuccess && (
        <p className="mt-2 text-green-400">
          メッセージが更新されました!
        </p>
      )}

      {error && (
        <p className="mt-2 text-red-400">
          エラー: {error.message.slice(0, 100)}
        </p>
      )}
    </form>
  );
}

メインページの組み立て

// src/app/page.tsx
"use client";

import { ConnectButton } from "@rainbow-me/rainbowkit";
import { useAccount } from "wagmi";
import { GreetingDisplay } from "@/components/GreetingDisplay";
import { GreetingForm } from "@/components/GreetingForm";

export default function Home() {
  const { isConnected } = useAccount();

  return (
    <main className="min-h-screen bg-gray-900 text-white p-8">
      <div className="max-w-2xl mx-auto">
        <h1 className="text-3xl font-bold mb-8">
          Greeter dApp
        </h1>

        {/* ウォレット接続ボタン */}
        <div className="mb-8">
          <ConnectButton />
        </div>

        {/* コントラクト情報 */}
        <div className="space-y-6">
          <GreetingDisplay />

          {isConnected ? (
            <GreetingForm />
          ) : (
            <p className="text-gray-400">
              メッセージを変更するにはウォレットを接続してください。
            </p>
          )}
        </div>
      </div>
    </main>
  );
}

環境変数ファイル

# .env.local
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your_project_id
NEXT_PUBLIC_ALCHEMY_KEY=your_alchemy_key
NEXT_PUBLIC_GREETER_ADDRESS=0xデプロイされたアドレス

開発サーバーの起動

# フロントエンド開発サーバー
npm run dev

ブラウザで http://localhost:3000 にアクセスし、MetaMaskで接続してコントラクトと対話してみてください。

ウォレット接続のフロー

ユーザーがConnectButtonをクリックすると、以下のフローが実行されます。

1234567.......RMwuUaeasIitgenamAbMicoMacwesoKtkuiantMta(s)kisConnected=true

まとめ

本章では、Next.js + wagmi + RainbowKitを使ったフロントエンドを構築し、GreeterコントラクトとブラウザUIを接続しました。コントラクトの読み取り(useReadContract)と書き込み(useWriteContract)の基本パターンを学びました。

このパターンは、後の章でNFTやレンタルマーケットプレイスのフロントエンドを構築する際にも同様に使用します。次章では、ERC721(NFT)コントラクトを作成します。

関連記事