ここからは 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 自身の RPC | MetaMask の中にしかない |
| 読み取り | ブラウザ → 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 // サーバ
);
}
useEffect で setState(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 のキーになる
- アプリ側に残高を出しておくと切り分けが速い