フロントエンドとブロックチェーンの接続

これまでの章で、Solidityによるスマートコントラクトの開発を進めてきました。本章では、Next.jsを使ったフロントエンドアプリケーションからMetaMaskウォレットに接続し、ブラウザ上で文化財NFTの操作を行えるようにします。

ウォレット接続には、wagmi(React Hooks for Ethereum)と ethers.js v6 を使用します。wagmiは、Reactコンポーネントからウォレット接続やコントラクト呼び出しを簡潔に記述するためのライブラリです。

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

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

# 必要なパッケージのインストール
npm install wagmi viem @tanstack/react-query
npm install @rainbow-me/rainbowkit
npm install ethers

wagmiの設定

wagmiの設定ファイルを作成し、Sepoliaテストネットへの接続を構成します。

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

export const config = getDefaultConfig({
  appName: "Cultural Heritage NFT",
  projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!,
  chains: [sepolia, hardhat],
  transports: {
    [sepolia.id]: http(
      `https://eth-sepolia.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_API_KEY}`
    ),
    [hardhat.id]: http("http://127.0.0.1:8545"),
  },
});

プロバイダーの設定

アプリケーション全体をwagmiとRainbowKitのプロバイダーで囲みます。

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

import { WagmiProvider } from "wagmi";
import { RainbowKitProvider } from "@rainbow-me/rainbowkit";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "@/config/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>
          {children}
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}
// src/app/layout.tsx
import { Providers } from "./providers";

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

ウォレット接続ボタン

RainbowKitの ConnectButton を使って、ウォレット接続UIを実装します。

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

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

export function WalletConnect() {
  const { address, isConnected, chain } = useAccount();

  return (
    <div className="p-4">
      <h2 className="text-xl font-bold mb-4">ウォレット接続</h2>
      <ConnectButton />

      {isConnected && (
        <div className="mt-4 p-4 bg-gray-100 rounded">
          <p>接続済み</p>
          <p className="text-sm font-mono">
            アドレス: {address}
          </p>
          <p className="text-sm">
            ネットワーク: {chain?.name}
          </p>
        </div>
      )}
    </div>
  );
}

コントラクトABIの準備

コントラクトのABIをフロントエンドで使えるように準備します。Hardhatでコンパイルすると、artifacts ディレクトリにABIが生成されます。

// src/config/contracts.ts
export const CULTURAL_HERITAGE_NFT_ADDRESS =
  process.env.NEXT_PUBLIC_CONTRACT_ADDRESS as `0x${string}`;

export const CULTURAL_HERITAGE_NFT_ABI = [
  {
    inputs: [
      { name: "to", type: "address" },
      { name: "uri", type: "string" },
      { name: "name", type: "string" },
      { name: "description", type: "string" },
      { name: "institution", type: "string" },
      { name: "dateCreated", type: "string" },
    ],
    name: "mintCulturalItem",
    outputs: [{ name: "", type: "uint256" }],
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    inputs: [{ name: "tokenId", type: "uint256" }],
    name: "getCulturalItem",
    outputs: [
      {
        components: [
          { name: "name", type: "string" },
          { name: "description", type: "string" },
          { name: "institution", type: "string" },
          { name: "dateCreated", type: "string" },
          { name: "mintedAt", type: "uint256" },
        ],
        type: "tuple",
      },
    ],
    stateMutability: "view",
    type: "function",
  },
  {
    inputs: [{ name: "tokenId", type: "uint256" }],
    name: "userOf",
    outputs: [{ name: "", type: "address" }],
    stateMutability: "view",
    type: "function",
  },
  {
    inputs: [
      { name: "tokenId", type: "uint256" },
      { name: "borrower", type: "address" },
      { name: "durationDays", type: "uint64" },
      { name: "purpose", type: "string" },
    ],
    name: "lendCulturalItem",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
  },
] as const;

コントラクトの読み取り

wagmiのフックを使って、コントラクトの状態を読み取ります。

// src/hooks/useCulturalItem.ts
"use client";

import { useReadContract } from "wagmi";
import {
  CULTURAL_HERITAGE_NFT_ADDRESS,
  CULTURAL_HERITAGE_NFT_ABI,
} from "@/config/contracts";

export function useCulturalItem(tokenId: number) {
  const { data, isLoading, error } = useReadContract({
    address: CULTURAL_HERITAGE_NFT_ADDRESS,
    abi: CULTURAL_HERITAGE_NFT_ABI,
    functionName: "getCulturalItem",
    args: [BigInt(tokenId)],
  });

  return {
    item: data,
    isLoading,
    error,
  };
}

export function useCurrentUser(tokenId: number) {
  const { data, isLoading } = useReadContract({
    address: CULTURAL_HERITAGE_NFT_ADDRESS,
    abi: CULTURAL_HERITAGE_NFT_ABI,
    functionName: "userOf",
    args: [BigInt(tokenId)],
  });

  return {
    user: data,
    isLoading,
  };
}

コントラクトへの書き込み(トランザクション署名)

NFTのミントやレンタルなど、状態を変更する操作にはトランザクションの署名が必要です。MetaMaskが署名の確認ダイアログを表示し、ユーザーの承認を得てからトランザクションが送信されます。

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

import { useState } from "react";
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import {
  CULTURAL_HERITAGE_NFT_ADDRESS,
  CULTURAL_HERITAGE_NFT_ABI,
} from "@/config/contracts";

export function MintForm() {
  const [name, setName] = useState("");
  const [description, setDescription] = useState("");
  const [institution, setInstitution] = useState("");
  const [dateCreated, setDateCreated] = useState("");
  const [metadataUri, setMetadataUri] = useState("");

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

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

  async function handleMint(e: React.FormEvent) {
    e.preventDefault();

    writeContract({
      address: CULTURAL_HERITAGE_NFT_ADDRESS,
      abi: CULTURAL_HERITAGE_NFT_ABI,
      functionName: "mintCulturalItem",
      args: [
        CULTURAL_HERITAGE_NFT_ADDRESS, // to (自分のアドレス)
        metadataUri,
        name,
        description,
        institution,
        dateCreated,
      ],
    });
  }

  return (
    <form onSubmit={handleMint} className="space-y-4 p-4">
      <h2 className="text-xl font-bold">文化財NFTの発行</h2>

      <div>
        <label className="block text-sm font-medium">文化財名</label>
        <input
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
          className="mt-1 block w-full border rounded p-2"
          placeholder="例:源氏物語絵巻"
        />
      </div>

      <div>
        <label className="block text-sm font-medium">説明</label>
        <textarea
          value={description}
          onChange={(e) => setDescription(e.target.value)}
          className="mt-1 block w-full border rounded p-2"
          placeholder="文化財の説明"
        />
      </div>

      <div>
        <label className="block text-sm font-medium">所蔵機関</label>
        <input
          type="text"
          value={institution}
          onChange={(e) => setInstitution(e.target.value)}
          className="mt-1 block w-full border rounded p-2"
          placeholder="例:東京国立博物館"
        />
      </div>

      <div>
        <label className="block text-sm font-medium">制作年代</label>
        <input
          type="text"
          value={dateCreated}
          onChange={(e) => setDateCreated(e.target.value)}
          className="mt-1 block w-full border rounded p-2"
          placeholder="例:12世紀"
        />
      </div>

      <div>
        <label className="block text-sm font-medium">
          メタデータURI (IPFS)
        </label>
        <input
          type="text"
          value={metadataUri}
          onChange={(e) => setMetadataUri(e.target.value)}
          className="mt-1 block w-full border rounded p-2"
          placeholder="ipfs://..."
        />
      </div>

      <button
        type="submit"
        disabled={isPending || isConfirming}
        className="bg-blue-600 text-white px-4 py-2 rounded
                   disabled:opacity-50"
      >
        {isPending
          ? "署名待ち..."
          : isConfirming
          ? "確認中..."
          : "NFTを発行"}
      </button>

      {isSuccess && (
        <p className="text-green-600">
          NFTの発行が完了しました TX: {hash}
        </p>
      )}

      {error && (
        <p className="text-red-600">
          エラー: {error.message}
        </p>
      )}
    </form>
  );
}

ethers.js v6 との連携

wagmiのフックだけでなく、ethers.js v6を使って直接コントラクトを操作することも可能です。特に、イベントのフィルタリングなど高度な操作ではethers.jsが便利です。

// src/hooks/useEthersContract.ts
import { useMemo } from "react";
import { ethers } from "ethers";
import { useAccount } from "wagmi";

export function useEthersContract() {
  const { address } = useAccount();

  const contract = useMemo(() => {
    if (typeof window === "undefined" || !window.ethereum) return null;

    const provider = new ethers.BrowserProvider(window.ethereum);

    return {
      // 読み取り用
      async getReadContract() {
        return new ethers.Contract(
          process.env.NEXT_PUBLIC_CONTRACT_ADDRESS!,
          CULTURAL_HERITAGE_NFT_ABI,
          provider
        );
      },
      // 書き込み用(署名付き)
      async getWriteContract() {
        const signer = await provider.getSigner();
        return new ethers.Contract(
          process.env.NEXT_PUBLIC_CONTRACT_ADDRESS!,
          CULTURAL_HERITAGE_NFT_ABI,
          signer
        );
      },
      // イベント取得
      async getHistory(tokenId: number) {
        const readContract = await this.getReadContract();
        const filter = readContract.filters.HistoryRecorded(tokenId);
        return readContract.queryFilter(filter);
      },
    };
  }, [address]);

  return contract;
}

メインページの組み立て

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

import { WalletConnect } from "@/components/WalletConnect";
import { MintForm } from "@/components/MintForm";
import { useAccount } from "wagmi";

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

  return (
    <main className="max-w-4xl mx-auto py-8">
      <h1 className="text-3xl font-bold mb-8">
        デジタル文化財管理システム
      </h1>

      <WalletConnect />

      {isConnected ? (
        <div className="mt-8">
          <MintForm />
        </div>
      ) : (
        <p className="mt-8 text-gray-500">
          ウォレットを接続してください。
        </p>
      )}
    </main>
  );
}

MetaMaskの設定

MetaMaskでSepoliaテストネットに接続するには、以下の手順が必要です。

  1. MetaMaskブラウザ拡張機能をインストール
  2. ネットワーク設定でSepoliaテストネットを選択(MetaMaskにはデフォルトで含まれています)
  3. Sepolia Faucetからテスト用ETHを取得

ローカル開発時には、Hardhat Networkを使用できます。

# Hardhatノードを起動
npx hardhat node

# MetaMaskにHardhat Networkを追加
# RPC URL: http://127.0.0.1:8545
# Chain ID: 31337

まとめ

本章では、wagmiとRainbowKitを使用してNext.jsフロントエンドからMetaMaskウォレットへの接続を実装しました。ユーザーはブラウザ上でウォレットを接続し、文化財NFTの発行・閲覧・レンタルといった操作を行えるようになりました。

本書を通じて、ブロックチェーンとIPFSを活用したデジタル文化財管理システムの基本的なアーキテクチャと実装方法を学びました。このシステムは試作段階ですが、文化財の真正性保証、来歴追跡、安全な貸借管理という、デジタル・ヒューマニティーズにおける重要な課題に対するブロックチェーン技術の可能性を示しています。

関連記事