ここからは Web 開発の領分です。ただし前提が 3 つ違います

まず、役割分担

ブラウザ ──┬─ Next.js サーバ ……… HTML と JS を配るだけ。チェーンには一切触らない
           │
           ├─ MetaMask ─────────→ 書き込み(署名してブロードキャスト)
           │
           └─ RPC エンドポイント ─→ 読み取り(eth_call)
                    │
                    ↓
              TicTacToe / SoloTicTacToe
              (状態 + ロジック + 認証 + 送金)

コントラクトが DB とロジックに加えて、認証と送金も兼ねています。 msg.sender が署名で保証されるのでセッション層が要らず、賭け金の保管と払い出しもコントラクト自身が行う。Web 開発なら認証基盤と決済基盤が別サービスになるところが、全部 1 つの中にあります。

そして重要なのが、Next.js 側は信頼境界ではないということ。フロントは特権を一切持っておらず、誰でも自作のフロントを書けます。実際、本書のコードは cast だけで 1 局完了させて賞金を引き出せます。フロントのバリデーションは UX のためだけです。

このアプリは Next.js の API Route を 1 本も使っていません。サーバは静的な資産を配っているだけです。

読みと書きで経路が違う

見落としやすいのがこれです。

経路秘密鍵
書き込みブラウザ → MetaMask → MetaMask 自身の RPCMetaMask の中にしかない
読み取りブラウザ → wagmi に設定した RPC不要

writeContractAsync がやっているのは「calldata を組み立てて MetaMask に eth_sendTransaction を渡す」ところまでで、そこから先の署名とブロードキャストは MetaMask が自分の接続先に対して行います。だから wagmi の transports に書いた URL は、実質読み取り専用の経路です。

ABI は生成する。手で書かない

一番の事故要因が ABI のずれです。コントラクトを変えたのにフロントの ABI が古いと、ビルドは通るのに実行時に落ちます。 これが最悪の壊れ方です。

@wagmi/cli の foundry プラグインが、forge build の出力から ABI と型付きフックを生成します。

// wagmi.config.ts
import { defineConfig } from '@wagmi/cli';
import { foundry, react } from '@wagmi/cli/plugins';

export default defineConfig({
  out: 'src/generated/wagmi.ts',
  plugins: [
    foundry({ project: '../contracts', include: ['TicTacToe.sol/**', 'SoloTicTacToe.sol/**'] }),
    react(),
  ],
});
cd contracts && forge build
cd ../web && npm run wagmi

この順序を守ること。 生成されるのは、こういうフックです。

useReadTicTacToeGetGame
useWriteTicTacToePlay
useSimulateTicTacToeJoinGame
useWatchTicTacToeGameFinishedEvent
...

関数名も引数の型も戻り値の構造体も、すべて Solidity の定義から来ています。play(gameId, cell) の cell に文字列を渡すと、TypeScript が止めます。

src/generated/.gitignore に入れず、コミットします(生成が壊れたときに気づけるため)。ただし手で編集はしません

クライアントコンポーネントに閉じ込める

wagmi は React context を使うので、プロバイダはクライアントコンポーネントです。ただし children は props として渡せるので、下のページはサーバコンポーネントのままにできます。

'use client';
export default function Web3Provider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: { queries: { staleTime: 2_000, retry: 1 } },
  }));

  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </WagmiProvider>
  );
}

useState(() => new QueryClient()) にしているのは、再レンダリングのたびに新しい QueryClient を作るとキャッシュが全部飛ぶからです。

ハイドレーションのずれを避ける

ウォレットの状態はブラウザにしか存在しません。サーバ描画時には「未接続」なので、そのまま書くと React がハイドレーション不一致を報告します。

'use client';
import { useSyncExternalStore } from 'react';

const noSubscribe = () => () => {};

export function useMounted(): boolean {
  return useSyncExternalStore(
    noSubscribe,
    () => true,   // クライアント
    () => false   // サーバ
  );
}

useEffectsetState(true) する定番の書き方でも動きますが、React 19 の react-hooks/set-state-in-effect ルールに引っかかります。useSyncExternalStore なら副作用なしで同じことができます。

トランザクションの進行を扱う

書き込みは 2 段階です。署名を待つ取り込みを待つ。エフェクトの連鎖にすると読みにくくなるので、非同期のイベントハンドラにまとめます。

export function useTxAction() {
  const config = useConfig();
  const [phase, setPhase] = useState<'idle' | 'signing' | 'confirming'>('idle');
  const [error, setError] = useState<unknown>(null);

  const run = useCallback(async (send: () => Promise<`0x${string}`>) => {
    setError(null);
    setPhase('signing');
    try {
      const hash = await send();
      setPhase('confirming');
      return await waitForTransactionReceipt(config, { hash });
    } catch (caught) {
      setError(caught);
      return null;
    } finally {
      setPhase('idle');
    }
  }, [config]);

  return { run, phase, error, isBusy: phase !== 'idle' };
}

使う側はこうなります。

async function play(cell: number) {
  setAttemptedCell(cell);
  const receipt = await playAction.run(() => sendPlay({ address: contract, args: [gameId, cell] }));
  setAttemptedCell(null);
  if (receipt) queryClient.invalidateQueries();
}

レシートを受け取った場所で、そのまま次の処理を書けるのが利点です。イベントログから ID を取り出す、といったこともここでやれます。

const [created] = parseEventLogs({ abi: soloTicTacToeAbi, eventName: 'GameCreated', logs: receipt.logs });
if (created) setGameId(created.args.gameId);

新しいゲームの ID はレシートのログから取る必要があります。gameCount() を読み直すと、同じブロックで他人がゲームを作った場合に競合するからです。

状態の更新はポーリングで足りる

相手の着手をどう検知するか。イベント購読(useWatchContractEvent)もありますが、本書は素朴にポーリングしています。

const { data: game } = useReadTicTacToeGetGame({
  address: contract,
  args: [gameId],
  query: { enabled: !!contract, refetchInterval: 4_000 },
});

理由は、WebSocket の再接続やイベントの取りこぼしを扱うより単純だからです。相手の手は 1〜2 ブロックで届くので、4 秒間隔で困りません。リアルタイム性が要るゲームなら話は別ですが、まるばつには過剰です。

カスタムエラーを翻訳する

コントラクトがカスタムエラーで失敗すると、viem がエラー名を復元してくれます。それを i18n のキーとして使います。

export function describeTxError(error: unknown): { key?: string; message: string } | null {
  if (error instanceof BaseError) {
    if (error.walk((e) => e instanceof UserRejectedRequestError)) {
      return { key: 'UserRejected', message: 'Transaction rejected in wallet.' };
    }
    const reverted = error.walk((e) => e instanceof ContractFunctionRevertedError);
    if (reverted instanceof ContractFunctionRevertedError && reverted.data?.errorName) {
      return { key: reverted.data.errorName, message: reverted.data.errorName };
    }
    return { message: error.shortMessage };
  }
  return { message: error instanceof Error ? error.message : String(error) };
}
{
  "CellTaken": "そのマスは既に埋まっています。",
  "NotYourTurn": "あなたの手番ではありません。",
  "JoinWindowClosed": "参加受付は締め切られました。"
}

Solidity の error CellTaken(uint8 cell); が、そのまま日本語のメッセージになる。 コントラクトからフロントまで、エラーの表現が型のある形でつながります。require("...") の文字列だとこれができません。

「ウォレットが違うネットワークにいる」を扱う

これは必ず起きます。そして新品のウォレットはローカルの anvil を知りません

async function goTo(target: SupportedChainId) {
  try {
    await switchChainAsync({ chainId: target });
  } catch {
    // 知らないネットワークなら、登録を提案してから切り替える
    await addChainToWallet(await connector?.getProvider(), chain);
    await switchChainAsync({ chainId: target });
  }
}

addChainToWallet の中身(EIP-3085 の wallet_addEthereumChain)と、それでも駄目なときの手動設定の値は第 8 章に書きました。ここで押さえておくのは、「知らないネットワークだった」が例外ではなく通常のパスだということです。switchChain の失敗を握り潰さず、登録を提案するところまでを 1 つのハンドラにしておきます。

なお、第 8 章で入れた残高表示はこの画面にそのまま残します。デバッグ用の仮設ではなく、賭け金を払えるかの判断にも要る情報だからです。

SSR と共存させる

本書は既存の Next.js テンプレート(next-intl + next-themes + Tailwind)をそのまま土台にしました。ウォレット周りをクライアントコンポーネントに閉じ込めれば、静的生成と共存できます

├   /[locale]/play
│ ├ ● /en/play          ← SSG
│ └ ● /ja/play
├ ƒ /[locale]/play/[gameId]   ← オンデマンド(ID は実行時に決まるので)

ゲーム ID はプレイヤーが実行時に作るので、詳細ページだけは動的です。それ以外は静的に出せます。

まとめ

  • フロントは信頼境界ではない。ルールはコントラクト側で強制する
  • 読みは自分の RPC、書きは MetaMask の RPC。経路が違う
  • ABI は生成する。手でコピーすると実行時にだけ壊れる
  • ウォレット依存の UI はマウント後にだけ描く
  • 書き込みは非同期ハンドラで扱うとレシートを素直に使える
  • カスタムエラーがそのまま i18n のキーになる
  • アプリ側に残高を出しておくと切り分けが速い